diff --git a/.agent-loop/initiatives/WS-ENG-ROOT-001-planning-intake-gate-recovery/STATUS.md b/.agent-loop/initiatives/WS-ENG-ROOT-001-planning-intake-gate-recovery/STATUS.md new file mode 100644 index 000000000..ee385ce69 --- /dev/null +++ b/.agent-loop/initiatives/WS-ENG-ROOT-001-planning-intake-gate-recovery/STATUS.md @@ -0,0 +1,9 @@ +# Status: WS-ENG-ROOT-001 — Planning-Intake Gate Recovery + +- Phase: one-use root recovery +- Active planning chunk: none +- Active implementation chunk: none +- Recovery target: `WS-ENG-ROOT-001-01` +- Signed basis and first parent: `339248c40020658583bf7bd1e4a58daf85f5ffb8` +- Purpose: restore the already-documented closed first-planning-intake admission +- Stop: recovery certificate is consumed by this exact merge and cannot replay diff --git a/.agent-loop/initiatives/WS-ENG-ROOT-001-planning-intake-gate-recovery/chunks/WS-ENG-ROOT-001-01-exact-planning-intake-gate-recovery.md b/.agent-loop/initiatives/WS-ENG-ROOT-001-planning-intake-gate-recovery/chunks/WS-ENG-ROOT-001-01-exact-planning-intake-gate-recovery.md new file mode 100644 index 000000000..4c9d148cc --- /dev/null +++ b/.agent-loop/initiatives/WS-ENG-ROOT-001-planning-intake-gate-recovery/chunks/WS-ENG-ROOT-001-01-exact-planning-intake-gate-recovery.md @@ -0,0 +1,109 @@ +# Chunk Contract: WS-ENG-ROOT-001-01 — Exact Root Planning-Intake Gate Recovery + +## Goal + +Repair the circular trusted gates that reject the repository's documented +first-new-initiative planning intake, using one exact consumed recovery. + +## Risk class + +L0 + +## Start phase + +`implementation` + +## Machine-checkable scope + +```chunk-scope-json +{ + "schema_version": 1, + "chunk_id": "WS-ENG-ROOT-001-01", + "phase": "implementation", + "risk_class": "L0", + "allowed_paths": [ + ".agent-loop/policies/loop-memory-recovery.json", + ".agent-loop/initiatives/WS-ENG-ROOT-001-planning-intake-gate-recovery/**", + ".agent-loop/merge-intents/WS-ENG-ROOT-001-01.json", + "scripts/check_chunk_contract.py", + "scripts/check_internal_review_evidence.py", + "scripts/check_loop_memory_state.py", + "scripts/update_post_merge_memory.py", + "scripts/test_agent_gates.py", + "scripts/test_check_chunk_contract.py", + "scripts/test_check_loop_memory_state.py", + "scripts/test_update_post_merge_memory.py" + ], + "forbidden_paths": ["backend/**", "frontend/**", ".github/**"], + "required_reviewers": ["senior engineering", "qa/test", "security/auth", "product/ops", "architecture", "ci integrity", "docs", "reuse/dedup", "test delta"], + "verification_commands": ["agent-gate-tests", "loop-memory-state", "loop-memory-recovery-tests", "chunk-scope-tests", "internal-review-evidence", "markdown-links", "stale-wording", "git-diff-check"] +} +``` + +## Allowed files + +```text +.agent-loop/policies/loop-memory-recovery.json +.agent-loop/initiatives/WS-ENG-ROOT-001-planning-intake-gate-recovery/** +.agent-loop/merge-intents/WS-ENG-ROOT-001-01.json +scripts/check_chunk_contract.py +scripts/check_internal_review_evidence.py +scripts/check_loop_memory_state.py +scripts/update_post_merge_memory.py +scripts/test_agent_gates.py +scripts/test_check_chunk_contract.py +scripts/test_check_loop_memory_state.py +scripts/test_update_post_merge_memory.py +``` + +## Not allowed + +```text +product code, backend, frontend, workflows, dependencies, coverage weakening +general unsigned implementation admission or reusable recovery authority +automatic start, merge, approval, cancellation, or AUTH implementation +``` + +## Acceptance criteria + +- [ ] Only a brand-new initiative's additive canonical planning tree and one + `-PLAN` intent can use the restored planning-intake admission. +- [ ] Existing initiatives, implementation/configuration paths, scripts, + workflows, policies, deletes, renames, links, and executable modes fail. +- [ ] Internal evidence accepts PLAN identity without inventing a PLAN contract, + while retaining exact reviewed-SHA and all required tracks. +- [ ] Ordinary implementation/specification chunks remain signed-start-only. +- [ ] Schema-v7 recovery is exact to this identity, signed basis, first parent, + empty recovered list, fixed reason/code, and is consumed on first use. +- [ ] Independent state validation rejects altered recovery evidence. +- [ ] Exactly one null-successor merge intent completes the root repair. + +## Verification commands + +```bash +python3 scripts/test_check_chunk_contract.py +python3 scripts/test_agent_gates.py +PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 python3 -m pytest -q scripts/test_update_post_merge_memory.py scripts/test_check_loop_memory_state.py +python3 scripts/check_loop_memory_state.py +python3 scripts/check_internal_review_evidence.py +python3 scripts/check_markdown_links.py +python3 scripts/check_stale_workstream_wording.py +git diff --check origin/main...HEAD +``` + +## Required reviewers + +- [ ] senior engineering +- [ ] QA/test +- [ ] security/auth +- [ ] product/ops +- [ ] architecture +- [ ] CI integrity +- [ ] docs +- [ ] reuse/dedup +- [ ] test delta + +## Stop conditions + +Stop if the recovery is reusable, changes product code, or admits anything +beyond the closed planning-intake tree. diff --git a/.agent-loop/initiatives/WS-ENG-ROOT-001-planning-intake-gate-recovery/reviews/WS-ENG-ROOT-001-01-external-review-response.md b/.agent-loop/initiatives/WS-ENG-ROOT-001-planning-intake-gate-recovery/reviews/WS-ENG-ROOT-001-01-external-review-response.md new file mode 100644 index 000000000..75e8d6b52 --- /dev/null +++ b/.agent-loop/initiatives/WS-ENG-ROOT-001-planning-intake-gate-recovery/reviews/WS-ENG-ROOT-001-01-external-review-response.md @@ -0,0 +1,20 @@ +# WS-ENG-ROOT-001-01 External Review Response + +Comments addressed: one CodeRabbit fail-closed finding. Independent planning +state validation now rejects a non-string `intent_path` with a stable failure +instead of raising, with a regression test for `null` input. + +Comments deferred: CodeRabbit's generic docstring-coverage warning is not a +changed-code defect and adding unrelated docstrings would violate this exact +recovery scope. The optional wider recovery runbook remains separate work. + +Human decisions needed: explicit approval of PR #205 remains required because +trusted `main` contains the circular Agent Gate being repaired. + +Commands rerun: focused loop-memory tests, chunk-contract tests, agent-gate +tests, independent state validation, internal evidence, Markdown links, stale +wording, and diff integrity. + +Remaining risks: the trusted-main Agent Gate must fail on this recovery PR; it +cannot consume candidate code without self-authorizing the repair. The exact +schema-v7 recovery is base-pinned, identity-pinned, path-pinned, and one-use. diff --git a/.agent-loop/initiatives/WS-ENG-ROOT-001-planning-intake-gate-recovery/reviews/WS-ENG-ROOT-001-01-internal-review-evidence.md b/.agent-loop/initiatives/WS-ENG-ROOT-001-planning-intake-gate-recovery/reviews/WS-ENG-ROOT-001-01-internal-review-evidence.md new file mode 100644 index 000000000..ae29d6a55 --- /dev/null +++ b/.agent-loop/initiatives/WS-ENG-ROOT-001-planning-intake-gate-recovery/reviews/WS-ENG-ROOT-001-01-internal-review-evidence.md @@ -0,0 +1,53 @@ +# WS-ENG-ROOT-001-01 Internal Review Evidence + +Reviewed code SHA: `60db7ce2d682319b0c14ec0920b6adc75654bab9` + +Reviewed at: `2026-07-26T14:00:00Z` + +Reviewer run IDs: `cr_senior`, `cr_qa`, `cr_security`, `cr_product`, +`cr_arch`, `cr_ci`, `cr_docs`, `cr_reuse`, `cr_testdelta` + +## Commands Run + +```bash +python3 scripts/test_agent_gates.py +python3 scripts/check_loop_memory_state.py +PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 python3 -m pytest -q scripts/test_update_post_merge_memory.py scripts/test_check_loop_memory_state.py +python3 scripts/test_check_chunk_contract.py +python3 scripts/check_internal_review_evidence.py +python3 scripts/check_markdown_links.py +python3 scripts/check_stale_workstream_wording.py +git diff --check origin/main...HEAD +``` + +## Reviewer Results + +| Reviewer | Result | Blocking findings | Notes | +|---|---|---|---| +| senior engineering | PASS WITH LOW RISKS | none | The malformed-path repair is minimal and retains exact recovery boundaries. | +| QA/test | PASS WITH LOW RISKS | none | The `null` intent-path regression proves a controlled fail-closed result. | +| security/auth | PASS | none | Ordinary implementation remains signed-start-only; recovery remains exact and one-use. | +| product/ops | PASS AFTER FIXES | none | The only procedural finding was this stale evidence, now rebound to the reviewed SHA. | +| architecture | PASS AFTER FIXES | none | The external-response path is explicitly re-reviewed as part of the closed certificate. | +| CI integrity | PASS WITH LOW RISKS | none | No workflow, threshold, exclusion, dependency, or coverage command was weakened. | +| docs | PASS AFTER FIXES | none | The external response is recorded separately and this evidence is rebound to the reviewed SHA. | +| reuse/dedup | PASS WITH LOW RISKS | none | Validator duplication is deliberate independent verification, not a forked helper. | +| test delta | PASS WITH LOW RISKS | none | No tests were removed, skipped, deselected, or weakened. | + +## Findings Resolved + +Valid findings addressed: yes + +Open sub-agent sessions: none + +The repaired implementation also rejects a non-string planning-intake +`intent_path` with a stable failure instead of raising. All nine reviewer +tracks reviewed the exact implementation SHA above. Their only procedural +finding was stale evidence after the external-review repair; this evidence-only +commit resolves it without changing the reviewed implementation. + +## Remaining Gate + +GitHub checks, external review, and the explicit human merge checkpoint remain. +The low-risk documentation suggestions are intentionally deferred because the +one-use recovery certificate permits only its exact closed path set. diff --git a/.agent-loop/initiatives/WS-ENG-ROOT-001-planning-intake-gate-recovery/reviews/WS-ENG-ROOT-001-01-pr-trust-bundle.md b/.agent-loop/initiatives/WS-ENG-ROOT-001-planning-intake-gate-recovery/reviews/WS-ENG-ROOT-001-01-pr-trust-bundle.md new file mode 100644 index 000000000..804306585 --- /dev/null +++ b/.agent-loop/initiatives/WS-ENG-ROOT-001-planning-intake-gate-recovery/reviews/WS-ENG-ROOT-001-01-pr-trust-bundle.md @@ -0,0 +1,39 @@ +# PR Trust Bundle: WS-ENG-ROOT-001-01 + +## Goal + +Restore the documented first-planning-intake path after PR #203 accidentally +made it impossible to enter, using one exact consumed root recovery. + +## Scope and behavior + +Only trusted engineering gates, their tests, the independent memory checker, +and the exact recovery certificate change. Product behavior is unchanged. +Ordinary work remains signed-start-only. + +## Why root recovery is required + +Trusted base code rejects both the repair and every normal planning intake, so +no ordinary signed contract can authorize the correction. The exact recovery +is bound to current signed main and this single identity, then consumed. + +## Human review focus + +- Closed planning tree grammar and signed-history absence. +- No candidate implementation self-authorization. +- Exact recovery certificate, consumption, and replay inertness. +- Continued signed-start requirement for ordinary chunks. + +## Human merge ownership + +The owner must explicitly approve this specific repair PR. A failing old scope +check is the defect being repaired and must not be represented as passing. + +## External review response + +CodeRabbit identified one valid fail-closed issue: a non-string planning-intake +`intent_path` could raise during independent state validation. The repair now +returns a controlled validation failure and includes an adversarial regression. +The Backend rerun passed after its first attempt hit a transient quay.io MinIO +image-pull timeout. The trusted-main Agent Gate remains the expected circular +failure that this exact recovery repairs. diff --git a/.agent-loop/merge-intents/WS-ENG-ROOT-001-01.json b/.agent-loop/merge-intents/WS-ENG-ROOT-001-01.json new file mode 100644 index 000000000..d5eea5c01 --- /dev/null +++ b/.agent-loop/merge-intents/WS-ENG-ROOT-001-01.json @@ -0,0 +1,9 @@ +{ + "schema_version": 2, + "initiative_id": "WS-ENG-ROOT-001", + "chunk_id": "WS-ENG-ROOT-001-01", + "chunk_title": "Exact Root Planning-Intake Gate Recovery", + "next_chunk_id": null, + "next_chunk_title": null, + "next_requires_explicit_start": true +} diff --git a/.agent-loop/policies/loop-memory-recovery.json b/.agent-loop/policies/loop-memory-recovery.json index f48ba739d..7ebe5c345 100644 --- a/.agent-loop/policies/loop-memory-recovery.json +++ b/.agent-loop/policies/loop-memory-recovery.json @@ -1,22 +1,9 @@ { "activation": { - "chunk_id": "WS-ENG-007-00R6", - "initiative_id": "WS-ENG-007" + "chunk_id": "WS-ENG-ROOT-001-01", + "initiative_id": "WS-ENG-ROOT-001" }, - "signed_basis": "bba4ba5f171a4438b072740707a5cf8bde49d9af", - "recovered_merges": [ - { - "chunk_id": "WS-ART-001-PLAN2", - "initiative_id": "WS-ART-001", - "merge_sha": "03a05eeb8f129e0d5f226cc5c058965f43590a81", - "pr_number": 197 - }, - { - "chunk_id": "WS-AUTH-001-11", - "initiative_id": "WS-AUTH-001", - "merge_sha": "f670b7058c71ad4d11a68c6e242e9fe501ae3aaf", - "pr_number": 201 - } - ], - "schema_version": 6 + "signed_basis": "339248c40020658583bf7bd1e4a58daf85f5ffb8", + "recovered_merges": [], + "schema_version": 7 } diff --git a/scripts/check_chunk_contract.py b/scripts/check_chunk_contract.py index eaa54cec2..919b08548 100644 --- a/scripts/check_chunk_contract.py +++ b/scripts/check_chunk_contract.py @@ -40,6 +40,7 @@ "review-log-archive-tests": "python3 scripts/test_check_review_log_archive.py", "review-log-archive-check": "python3 scripts/check_review_log_archive.py", "loop-memory-state": "python3 scripts/check_loop_memory_state.py", + "loop-memory-recovery-tests": "PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 python3 -m pytest -q scripts/test_update_post_merge_memory.py scripts/test_check_loop_memory_state.py", "stale-artifact-contracts": "python3 scripts/check_stale_artifact_contracts.py", } VERIFICATION_COMMAND_IDS = frozenset(VERIFICATION_COMMANDS) @@ -55,6 +56,40 @@ FENCE_RE = re.compile(r"^```chunk-scope-json[ \t]*\n(?P.*?)^```[ \t]*$", re.M | re.S) CONTROL_RE = re.compile(r"[\x00-\x1f\x7f-\x9f]") GLOB_META_RE = re.compile(r"[*?\[\]{}!\\]") +PLANNING_ROOT_FILES = frozenset({ + "INTENT.md", "DISCOVERY.md", "PLAN.md", "CHUNK_MAP.md", "STATUS.md", + "RISKS.md", "DECISIONS.md", +}) +ROOT_RECOVERY_SIGNED_BASIS = "339248c40020658583bf7bd1e4a58daf85f5ffb8" +ROOT_RECOVERY_INITIATIVE = "WS-ENG-ROOT-001" +ROOT_RECOVERY_CHUNK = "WS-ENG-ROOT-001-01" +ROOT_RECOVERY_CONTRACT = ( + ".agent-loop/initiatives/WS-ENG-ROOT-001-planning-intake-gate-recovery/" + "chunks/WS-ENG-ROOT-001-01-exact-planning-intake-gate-recovery.md" +) +ROOT_RECOVERY_ALLOWED = ( + ".agent-loop/policies/loop-memory-recovery.json", + ".agent-loop/initiatives/WS-ENG-ROOT-001-planning-intake-gate-recovery/**", + ".agent-loop/merge-intents/WS-ENG-ROOT-001-01.json", + "scripts/check_chunk_contract.py", + "scripts/check_internal_review_evidence.py", + "scripts/check_loop_memory_state.py", + "scripts/update_post_merge_memory.py", + "scripts/test_agent_gates.py", + "scripts/test_check_chunk_contract.py", + "scripts/test_check_loop_memory_state.py", + "scripts/test_update_post_merge_memory.py", +) +ROOT_RECOVERY_PATHS = frozenset({ + ".agent-loop/policies/loop-memory-recovery.json", + ".agent-loop/merge-intents/WS-ENG-ROOT-001-01.json", + ".agent-loop/initiatives/WS-ENG-ROOT-001-planning-intake-gate-recovery/STATUS.md", + ROOT_RECOVERY_CONTRACT, + ".agent-loop/initiatives/WS-ENG-ROOT-001-planning-intake-gate-recovery/reviews/WS-ENG-ROOT-001-01-internal-review-evidence.md", + ".agent-loop/initiatives/WS-ENG-ROOT-001-planning-intake-gate-recovery/reviews/WS-ENG-ROOT-001-01-external-review-response.md", + ".agent-loop/initiatives/WS-ENG-ROOT-001-planning-intake-gate-recovery/reviews/WS-ENG-ROOT-001-01-pr-trust-bundle.md", + *ROOT_RECOVERY_ALLOWED[3:], +}) class ContractError(ValueError): @@ -379,7 +414,7 @@ def _git_json(repo: Path, revision_path: str) -> dict[str, Any]: return value -def added_merge_intent(repo: Path, base: str, head: str) -> dict[str, str]: +def added_merge_intent(repo: Path, base: str, head: str) -> dict[str, Any]: raw = _git(repo, "diff", "--name-only", "--diff-filter=A", "-z", f"{base}...{head}") paths = validate_path_bytes(path for path in raw.split(b"\0") if path) intents = [ @@ -395,7 +430,187 @@ def added_merge_intent(repo: Path, base: str, head: str) -> dict[str, str]: raise ContractError("merge intent has invalid initiative/chunk identity") if not chunk.startswith(initiative + "-"): raise ContractError("merge intent chunk does not belong to initiative") - return {"path": intents[0], "initiative_id": initiative, "chunk_id": chunk} + return {**data, "path": intents[0]} + + +def planning_intake_scope( + repo: Path, base: str, head: str, intent: dict[str, Any], + records: Sequence[dict[str, Any]], +) -> ScopeContract | None: + """Admit the one closed additive tree used to introduce a new initiative.""" + initiative = intent["initiative_id"] + chunk = intent["chunk_id"] + if chunk != f"{initiative}-PLAN": + return None + successor = intent.get("next_chunk_id") + if ( + type(successor) is not str + or not successor.startswith(initiative + "-") + or intent.get("next_requires_explicit_start") is not True + ): + raise ContractError("planning intake requires one same-initiative explicit-start successor") + base_tree = _git( + repo, "ls-tree", "-r", "--name-only", "-z", base, + "--", ".agent-loop/initiatives", + ) + base_paths = validate_path_bytes(path for path in base_tree.split(b"\0") if path) + initiative_root = f".agent-loop/initiatives/{initiative}" + if any( + path.startswith(initiative_root + "/") + or path.startswith(initiative_root + "-") + for path in base_paths + ): + raise ContractError("planning intake initiative already exists in trusted base tree") + for record in records: + event = record.get("event") + completed = record.get("completed_chunk") + intake = record.get("planning_intake") + if ( + (type(event) is dict and event.get("initiative_id") == initiative) + or (type(completed) is dict and completed.get("initiative_id") == initiative) + or (type(intake) is dict and intake.get("initiative_id") == initiative) + ): + raise ContractError("planning intake initiative already exists in signed history") + + raw = _git(repo, "diff", "--name-status", "-z", f"{base}...{head}") + statuses = parse_name_status_z(raw) + if not statuses or any(status != "A" for status, _names in statuses): + raise ContractError("planning intake permits additive files only") + paths = validate_path_bytes(name for _status, names in statuses for name in names) + if intent["path"] not in paths: + raise ContractError("planning intake merge intent is not in the additive delta") + + prefix = f".agent-loop/initiatives/{initiative}-" + directories = { + path.split("/", 3)[2] + for path in paths if path.startswith(prefix) and path.count("/") >= 3 + } + if len(directories) != 1: + raise ContractError("planning intake must add one canonical initiative directory") + directory = next(iter(directories)) + if not re.fullmatch(rf"{re.escape(initiative)}-[a-z0-9]+(?:-[a-z0-9]+)*", directory): + raise ContractError("planning intake initiative directory is noncanonical") + root = f".agent-loop/initiatives/{directory}/" + if any(path != intent["path"] and not path.startswith(root) for path in paths): + raise ContractError("planning intake contains a foreign path") + + root_files: set[str] = set() + chunks: list[str] = [] + reviews: set[str] = set() + for path in paths: + if path == intent["path"]: + continue + relative = path.removeprefix(root) + parts = relative.split("/") + if any(part.startswith(".") or part.casefold() == "agents.md" for part in parts): + raise ContractError("planning intake path grammar is invalid") + if "/" not in relative: + root_files.add(relative) + elif relative.startswith("chunks/") and relative.count("/") == 1: + filename = parts[-1] + if not re.fullmatch( + rf"{re.escape(initiative)}-[A-Z0-9]+(?:-[A-Z0-9]+)*" + r"(?:-[a-z0-9]+(?:-[a-z0-9]+)*)?\.md", filename, + ): + raise ContractError("planning intake chunk name is invalid") + chunks.append(path) + elif relative.startswith("reviews/") and relative.count("/") == 1: + reviews.add(parts[-1]) + else: + raise ContractError("planning intake path grammar is invalid") + if root_files not in {PLANNING_ROOT_FILES, PLANNING_ROOT_FILES | {"REVIEW_LOG.md"}}: + raise ContractError("planning intake root file set is invalid") + expected_reviews = { + f"{initiative}-PLAN-internal-review-evidence.md", + f"{initiative}-PLAN-pr-trust-bundle.md", + } + if reviews != expected_reviews or not chunks: + raise ContractError("planning intake review or contract set is invalid") + successor_contracts: list[ScopeContract] = [] + for path in chunks: + try: + candidate = parse_contract_bytes(_git(repo, "show", f"{head}:{path}")) + except ContractError as exc: + raise ContractError(f"planning intake contains invalid successor contract: {exc}") from exc + if candidate.chunk_id == successor: + successor_contracts.append(candidate) + if len(successor_contracts) != 1 or successor_contracts[0].phase != "implementation": + raise ContractError("planning intake successor contract is not exact implementation scope") + status = _decode_utf8(_git(repo, "show", f"{head}:{root}STATUS.md"), "planning status") + for label in ("planning", "implementation"): + if not re.search(rf"(?mi)^- Active {label} chunk:\s*(?:`?none`?)\s*$", status): + raise ContractError("planning intake status claims active work") + return ScopeContract(chunk, "planning", "L1", paths, (), (), ()) + + +def root_recovery_scope( + repo: Path, base: str, head: str, intent: dict[str, Any] +) -> ScopeContract | None: + """Consume the exact one-use certificate for the root gate repair only.""" + if intent["chunk_id"] != ROOT_RECOVERY_CHUNK: + return None + if intent["initiative_id"] != ROOT_RECOVERY_INITIATIVE: + raise ContractError("root recovery merge intent identity is invalid") + resolved_base = _decode_utf8( + _git(repo, "rev-parse", f"{base}^{{commit}}"), "root recovery base" + ).strip() + if resolved_base != ROOT_RECOVERY_SIGNED_BASIS: + raise ContractError("root recovery is not based on the exact trusted commit") + policy = _git_json( + repo, f"{head}:.agent-loop/policies/loop-memory-recovery.json" + ) + expected_policy = { + "activation": { + "chunk_id": ROOT_RECOVERY_CHUNK, + "initiative_id": ROOT_RECOVERY_INITIATIVE, + }, + "signed_basis": ROOT_RECOVERY_SIGNED_BASIS, + "recovered_merges": [], + "schema_version": 7, + } + if policy != expected_policy: + raise ContractError("root recovery certificate is invalid, consumed, or reusable") + raw = _git(repo, "diff", "--name-status", "-z", f"{base}...{head}") + statuses = parse_name_status_z(raw) + if any(status not in {"A", "M"} for status, _names in statuses): + raise ContractError("root recovery permits only its exact added or modified files") + paths = validate_path_bytes(name for _status, names in statuses for name in names) + if frozenset(paths) != ROOT_RECOVERY_PATHS: + raise ContractError("root recovery delta does not match its exact path certificate") + contract_raw = _git(repo, "show", f"{head}:{ROOT_RECOVERY_CONTRACT}") + contract_text = _decode_utf8(contract_raw) + heading = HEADING_RE.match(contract_text) + try: + scope_data = json.loads( + machine_block(contract_raw), object_pairs_hook=_object + ) + except (json.JSONDecodeError, ContractError) as exc: + raise ContractError(f"root recovery contract scope is malformed: {exc}") from exc + expected_scope = { + "schema_version": 1, + "chunk_id": ROOT_RECOVERY_CHUNK, + "phase": "implementation", + "risk_class": "L0", + "allowed_paths": list(ROOT_RECOVERY_ALLOWED), + "forbidden_paths": ["backend/**", "frontend/**", ".github/**"], + "required_reviewers": [ + "senior engineering", "qa/test", "security/auth", "product/ops", + "architecture", "ci integrity", "docs", "reuse/dedup", "test delta", + ], + "verification_commands": [ + "agent-gate-tests", "loop-memory-state", "loop-memory-recovery-tests", "chunk-scope-tests", + "internal-review-evidence", "markdown-links", "stale-wording", + "git-diff-check", + ], + } + if heading is None or heading.group("id") != ROOT_RECOVERY_CHUNK or scope_data != expected_scope: + raise ContractError("root recovery contract scope is not exact") + return ScopeContract( + ROOT_RECOVERY_CHUNK, "implementation", "L0", ROOT_RECOVERY_ALLOWED, + ("backend/**", "frontend/**", ".github/**"), + tuple(expected_scope["required_reviewers"]), + tuple(expected_scope["verification_commands"]), + ) @dataclass(frozen=True) @@ -604,10 +819,16 @@ def _human_phase_risk(raw: bytes) -> tuple[str, str]: def select_contract( repo: Path, base: str, head: str, state_ref: str -) -> tuple[ScopeContract, SignedStart]: +) -> tuple[ScopeContract, SignedStart | None]: intent = added_merge_intent(repo, base, head) verify_state_ref(repo, state_ref) records = authenticated_ledger(repo, state_ref) + intake = planning_intake_scope(repo, base, head, intent, records) + if intake is not None: + return intake, None + recovery = root_recovery_scope(repo, base, head, intent) + if recovery is not None: + return recovery, None start = latest_signed_start(records, intent["chunk_id"]) if start.initiative_id != intent["initiative_id"]: raise ContractError("signed start initiative disagrees with merge intent") diff --git a/scripts/check_internal_review_evidence.py b/scripts/check_internal_review_evidence.py index f408ad915..7e0d60e75 100644 --- a/scripts/check_internal_review_evidence.py +++ b/scripts/check_internal_review_evidence.py @@ -520,7 +520,7 @@ def active_merge_intent_chunk(paths: list[str]) -> str | None: return candidates[0] -def machine_contract_for(chunk_id: str) -> ScopeContract | None: +def machine_contract_for(chunk_id: str, *, planning_intake: bool = False) -> ScopeContract | None: """Load the current chunk's schema-v1 contract, if it has crossed cutover.""" matches: list[Path] = [] for path in (ROOT / ".agent-loop/initiatives").glob("*/chunks/*.md"): @@ -530,6 +530,10 @@ def machine_contract_for(chunk_id: str) -> ScopeContract | None: raise RuntimeError(f"cannot read chunk contract {path}") from exc if chunk_id_from_heading(first_line) == chunk_id.lower(): matches.append(path) + if not matches and planning_intake and chunk_id.endswith("-PLAN"): + # The PLAN identity describes the reviewed additive planning tree; its + # one implementation successor carries the first chunk contract. + return None if len(matches) != 1: raise RuntimeError( f"expected one contract for {chunk_id}, found {len(matches)}" @@ -607,8 +611,12 @@ def main() -> int: try: chunk_ids = required_chunk_ids(changed) intent_chunk = active_merge_intent_chunk(changed) + if intent_chunk is not None and intent_chunk.lower() not in chunk_ids: + chunk_ids.append(intent_chunk.lower()) machine_contract = ( - machine_contract_for(intent_chunk) if intent_chunk is not None else None + machine_contract_for( + intent_chunk, planning_intake=intent_chunk.endswith("-PLAN") + ) if intent_chunk is not None else None ) except RuntimeError as exc: print(f"Internal review evidence gate failed closed: {exc}", file=sys.stderr) diff --git a/scripts/check_loop_memory_state.py b/scripts/check_loop_memory_state.py index 5a881d84b..246544cb8 100644 --- a/scripts/check_loop_memory_state.py +++ b/scripts/check_loop_memory_state.py @@ -111,6 +111,11 @@ GITHUB_ACTIONS_APP_SLUG = "github-actions" R3_RECOVERY_CERTIFICATE_SHA256 = "4fe49b2f4a5a7ad18382a717dcc11f798c465a534066102bf9810c9ed5784f4a" R3_HISTORICAL_HEAD_SHA = "55a11d9e0ae356734dbcce73564f5f570220a81b" +ROOT_RECOVERY_SIGNED_BASIS = "339248c40020658583bf7bd1e4a58daf85f5ffb8" +ROOT_RECOVERY_CHUNK_ID = "WS-ENG-ROOT-001-01" +ROOT_RECOVERY_REASON = "planning-intake-gate-circularity" +ROOT_RECOVERY_CODE = "exact-root-gate-repair-v1" +ROOT_RECOVERY_CERTIFICATE_SHA256 = "32f75b9709e6b09b30e672cc9889a8754dad1428e0b57e06d6bd237ae6476e40" ID_PATTERN = re.compile(r"^[A-Z][A-Z0-9]*(?:-[A-Z0-9]+)+$") SHA_PATTERN = re.compile(r"^[0-9a-f]{40}$") SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$") @@ -411,6 +416,25 @@ def _planning_tree_failures( or after[0] != intake.get("merge_tree_sha") ): return [f"{label}: planning intake tree identity is invalid"] + intent_path = source.get("intent_path") + if not isinstance(intent_path, str): + return [f"{label}: planning intake intent path is invalid"] + initiative = intent_path.removeprefix( + ".agent-loop/merge-intents/" + ).removesuffix("-PLAN.json") + prefixes = ( + f".agent-loop/initiatives/{initiative}/", + f".agent-loop/initiatives/{initiative}-", + ) + base = _git_tree(repository_root, intake.get("base_tree_sha")) + if base is None: + return [f"{label}: planning intake reviewed base tree is unavailable"] + if any( + path.startswith(prefixes) + for entries in (base[1], before[1]) + for path in entries + ): + return [f"{label}: planning intake initiative tree already exists"] delta = { path: after[1].get(path) for path in sorted(set(before[1]) | set(after[1])) @@ -844,9 +868,32 @@ def _record_failures( failures.append(f"{label}: invalid protected check evidence") elif set(protected) == {"schema_version", "recovery_only", "sha256"}: recovery = protected.get("recovery_only") - expected_recovery = {"merge_sha": "d3321698fb856f3fac320cdc7bc598f813fe1953", "head_sha": R3_HISTORICAL_HEAD_SHA, "chunk_id": "WS-ENG-007-00R2", "pr_number": 189, "policy_schema": 4, "signed_basis": "73b457925b02301587b83d01ced0adb66319d134", "activation_chunk_id": "WS-ENG-007-00R3", "certificate_sha256": R3_RECOVERY_CERTIFICATE_SHA256, "reason": "no-completed-pre-merge-agent-gates"} - if recovery != expected_recovery or source.get("head_sha") != R3_HISTORICAL_HEAD_SHA or source.get("pr_number") != 189 or protected.get("sha256") != hashlib.sha256(json.dumps(expected_recovery, sort_keys=True, separators=(",", ":")).encode()).hexdigest(): - failures.append(f"{label}: invalid historical recovery evidence") + if isinstance(recovery, dict) and recovery.get("policy_schema") == 7: + expected_recovery = { + "merge_sha": source.get("main_sha"), "head_sha": source.get("head_sha"), + "chunk_id": ROOT_RECOVERY_CHUNK_ID, "pr_number": source.get("pr_number"), + "policy_schema": 7, "signed_basis": ROOT_RECOVERY_SIGNED_BASIS, + "activation_chunk_id": ROOT_RECOVERY_CHUNK_ID, + "certificate_sha256": ROOT_RECOVERY_CERTIFICATE_SHA256, + "reason": ROOT_RECOVERY_REASON, "code": ROOT_RECOVERY_CODE, + } + completed = record.get("completed_chunk") + if ( + recovery != expected_recovery + or protected.get("sha256") != hashlib.sha256(json.dumps(expected_recovery, sort_keys=True, separators=(",", ":")).encode()).hexdigest() + or not isinstance(completed, dict) + or completed.get("initiative_id") != "WS-ENG-ROOT-001" + or completed.get("chunk_id") != ROOT_RECOVERY_CHUNK_ID + or completed.get("next_chunk_id") is not None + or completed.get("next_chunk_title") is not None + or source.get("intent_path") != f".agent-loop/merge-intents/{ROOT_RECOVERY_CHUNK_ID}.json" + or source.get("first_parent_sha") != ROOT_RECOVERY_SIGNED_BASIS + ): + failures.append(f"{label}: invalid root recovery evidence") + else: + expected_recovery = {"merge_sha": "d3321698fb856f3fac320cdc7bc598f813fe1953", "head_sha": R3_HISTORICAL_HEAD_SHA, "chunk_id": "WS-ENG-007-00R2", "pr_number": 189, "policy_schema": 4, "signed_basis": "73b457925b02301587b83d01ced0adb66319d134", "activation_chunk_id": "WS-ENG-007-00R3", "certificate_sha256": R3_RECOVERY_CERTIFICATE_SHA256, "reason": "no-completed-pre-merge-agent-gates"} + if recovery != expected_recovery or source.get("head_sha") != R3_HISTORICAL_HEAD_SHA or source.get("pr_number") != 189 or protected.get("sha256") != hashlib.sha256(json.dumps(expected_recovery, sort_keys=True, separators=(",", ":")).encode()).hexdigest(): + failures.append(f"{label}: invalid historical recovery evidence") elif set(protected) == {"schema_version", "selected", "sha256"}: selected = protected.get("selected") if not isinstance(selected, dict) or set(selected) != {"agent-gates", "test"} or protected.get("sha256") != hashlib.sha256(json.dumps(selected, sort_keys=True, separators=(",", ":")).encode()).hexdigest(): diff --git a/scripts/test_agent_gates.py b/scripts/test_agent_gates.py index cf109a65e..12af282eb 100644 --- a/scripts/test_agent_gates.py +++ b/scripts/test_agent_gates.py @@ -376,6 +376,34 @@ def test_machine_scope_binds_reviewer_routing_and_verification_evidence() -> Non ) +def test_review_gate_allows_only_missing_plan_contract_for_planning_intake() -> None: + """A PLAN intake has evidence identity but no PLAN chunk contract.""" + gate = load_module( + "review_gate_planning_intake", "scripts/check_internal_review_evidence.py" + ) + with tempfile.TemporaryDirectory() as tmpdir: + original_root = gate.ROOT + gate.ROOT = Path(tmpdir) + try: + assert gate.machine_contract_for( + "WS-NEW-001-PLAN", planning_intake=True + ) is None + try: + gate.machine_contract_for("WS-NEW-001-01") + except RuntimeError as exc: + assert "expected one contract" in str(exc) + else: + raise AssertionError("ordinary missing chunk contract did not fail closed") + try: + gate.machine_contract_for("WS-NEW-001-PLAN", planning_intake=False) + except RuntimeError as exc: + assert "expected one contract" in str(exc) + else: + raise AssertionError("unclassified PLAN contract absence bypassed the gate") + finally: + gate.ROOT = original_root + + def test_review_evidence_files_are_not_relevant_changes() -> None: """Review evidence files satisfy the gate without requiring more evidence.""" gate = load_module( @@ -2356,30 +2384,17 @@ def get_json(self, _path: str): ) -def test_ws_eng_007_recovery_policy_is_exactly_pinned() -> None: - """The temporary production recovery authority is identity-exact.""" +def test_root_recovery_policy_is_exactly_pinned() -> None: + """The one-use planning-intake gate recovery is identity-exact.""" policy = json.loads(Path(".agent-loop/policies/loop-memory-recovery.json").read_text()) assert policy == { "activation": { - "chunk_id": "WS-ENG-007-00R6", - "initiative_id": "WS-ENG-007", + "chunk_id": "WS-ENG-ROOT-001-01", + "initiative_id": "WS-ENG-ROOT-001", }, - "signed_basis": "bba4ba5f171a4438b072740707a5cf8bde49d9af", - "recovered_merges": [ - { - "chunk_id": "WS-ART-001-PLAN2", - "initiative_id": "WS-ART-001", - "merge_sha": "03a05eeb8f129e0d5f226cc5c058965f43590a81", - "pr_number": 197, - }, - { - "chunk_id": "WS-AUTH-001-11", - "initiative_id": "WS-AUTH-001", - "merge_sha": "f670b7058c71ad4d11a68c6e242e9fe501ae3aaf", - "pr_number": 201, - }, - ], - "schema_version": 6, + "signed_basis": "339248c40020658583bf7bd1e4a58daf85f5ffb8", + "recovered_merges": [], + "schema_version": 7, } @@ -7743,6 +7758,7 @@ def main() -> int: test_backend_config_paths_require_review_evidence, test_materialized_evidence_gate_uses_explicit_repository_root, test_machine_scope_binds_reviewer_routing_and_verification_evidence, + test_review_gate_allows_only_missing_plan_contract_for_planning_intake, test_review_evidence_files_are_not_relevant_changes, test_evidence_requires_completed_yes_statements, test_evidence_must_reference_changed_chunk, @@ -7781,7 +7797,7 @@ def main() -> int: test_planning_intake_record_schema_fails_closed, test_independent_checker_accepts_and_mutates_planning_intake_state, test_planning_tree_entries_canonicalize_recursive_directory_objects, - test_ws_eng_007_recovery_policy_is_exactly_pinned, + test_root_recovery_policy_is_exactly_pinned, test_planning_checks_canonicalize_trusted_reruns_and_fail_closed, test_planning_intake_collection_binds_paths_trees_and_check_sources, test_eng006_exact_recovery_certificate_is_consumed_and_inert_on_replay, diff --git a/scripts/test_check_chunk_contract.py b/scripts/test_check_chunk_contract.py index 32ff35f57..71fbc1937 100644 --- a/scripts/test_check_chunk_contract.py +++ b/scripts/test_check_chunk_contract.py @@ -82,7 +82,7 @@ def test_verification_identifier_registry_is_closed(self) -> None: "authorization-property-tests", "authorization-property-lint", "mutation-policy-tests", "mutation-policy-lint", "review-log-archive-tests", "review-log-archive-check", - "loop-memory-state", "stale-artifact-contracts", + "loop-memory-state", "loop-memory-recovery-tests", "stale-artifact-contracts", })) def test_positive_schema_identity_reviewer_and_command(self) -> None: @@ -388,6 +388,204 @@ def test_select_rejects_stopped_and_post_cutover_no_schema_starts(self) -> None: checker.select_contract(Path("."), "base", "head", "state") +class PlanningIntakeSelectionTests(unittest.TestCase): + def git(self, repo: Path, *args: str) -> bytes: + return subprocess.run( + ["git", *args], cwd=repo, check=True, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, + ).stdout.strip() + + def fixture( + self, directory: str, *, foreign: str | None = None, + base_existing: str | None = None, + ) -> tuple[Path, str, dict[str, object]]: + repo = Path(directory) + self.git(repo, "init", "-q", "-b", "main") + self.git(repo, "config", "user.email", "test@example.invalid") + self.git(repo, "config", "user.name", "Test") + (repo / "README.md").write_text("trusted base\n") + if base_existing: + existing = repo / f".agent-loop/initiatives/{base_existing}/STATUS.md" + existing.parent.mkdir(parents=True) + existing.write_text("existing initiative\n") + self.git(repo, "add", ".") + self.git(repo, "commit", "-qm", "base") + base = self.git(repo, "rev-parse", "HEAD").decode() + initiative = "WS-NEW-001" + root = repo / f".agent-loop/initiatives/{initiative}-planning" + (root / "chunks").mkdir(parents=True) + (root / "reviews").mkdir() + for name in checker.PLANNING_ROOT_FILES: + text = ( + "- Active planning chunk: `none`\n" + "- Active implementation chunk: `none`\n" + if name == "STATUS.md" else f"# {name}\n" + ) + (root / name).write_text(text) + successor = contract().replace(b"WS-ENG-008-01", b"WS-NEW-001-01") + (root / "chunks/WS-NEW-001-01-implementation.md").write_bytes(successor) + (root / "reviews/WS-NEW-001-PLAN-internal-review-evidence.md").write_text("review\n") + (root / "reviews/WS-NEW-001-PLAN-pr-trust-bundle.md").write_text("trust\n") + intent_path = repo / ".agent-loop/merge-intents/WS-NEW-001-PLAN.json" + intent_path.parent.mkdir(parents=True) + intent_path.write_text(json.dumps({ + "schema_version": 2, "initiative_id": initiative, + "chunk_id": f"{initiative}-PLAN", "chunk_title": "Planning intake", + "next_chunk_id": f"{initiative}-01", "next_chunk_title": "First chunk", + "next_requires_explicit_start": True, + })) + if foreign: + path = repo / foreign + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("unauthorized\n") + self.git(repo, "add", ".") + self.git(repo, "commit", "-qm", "planning intake") + intent = checker.added_merge_intent(repo, base, "HEAD") + return repo, base, intent + + def test_first_new_initiative_planning_intake_gets_exact_additive_scope(self) -> None: + with tempfile.TemporaryDirectory() as directory: + repo, base, intent = self.fixture(directory) + scope = checker.planning_intake_scope(repo, base, "HEAD", intent, ()) + self.assertIsNotNone(scope) + assert scope is not None + self.assertEqual(scope.chunk_id, "WS-NEW-001-PLAN") + self.assertEqual(scope.phase, "planning") + checker.enforce_scope(scope, checker.discover_changes(repo, base, "HEAD")) + + def test_planning_intake_rejects_foreign_and_existing_initiative(self) -> None: + for foreign in ( + "scripts/implementation.py", + ".github/workflows/bypass.yml", + ".agent-loop/policies/broadened.json", + ): + with self.subTest(foreign=foreign), tempfile.TemporaryDirectory() as directory: + repo, base, intent = self.fixture(directory, foreign=foreign) + with self.assertRaisesRegex(checker.ContractError, "foreign path"): + checker.planning_intake_scope(repo, base, "HEAD", intent, ()) + with tempfile.TemporaryDirectory() as directory: + repo, base, intent = self.fixture(directory) + records = ({"event": { + "type": "start", "initiative_id": "WS-NEW-001", + "chunk_id": "WS-NEW-001-00", + }},) + with self.assertRaisesRegex(checker.ContractError, "already exists"): + checker.planning_intake_scope(repo, base, "HEAD", intent, records) + + def test_planning_intake_rejects_nonadditive_and_bad_successor(self) -> None: + with tempfile.TemporaryDirectory() as directory: + repo, base, intent = self.fixture(directory) + (repo / "README.md").write_text("modified by intake\n") + self.git(repo, "add", "README.md") + self.git(repo, "commit", "-qm", "smuggled modification") + with self.assertRaisesRegex(checker.ContractError, "additive files only"): + checker.planning_intake_scope(repo, base, "HEAD", intent, ()) + with tempfile.TemporaryDirectory() as directory: + repo, base, intent = self.fixture(directory) + intent["next_chunk_id"] = "WS-NEW-001-99" + with self.assertRaisesRegex(checker.ContractError, "successor contract"): + checker.planning_intake_scope(repo, base, "HEAD", intent, ()) + + def test_planning_intake_rejects_existing_base_tree_initiative(self) -> None: + for existing in ("WS-NEW-001", "WS-NEW-001-existing"): + with self.subTest(existing=existing), tempfile.TemporaryDirectory() as directory: + repo, base, intent = self.fixture(directory, base_existing=existing) + with self.assertRaisesRegex(checker.ContractError, "trusted base tree"): + checker.planning_intake_scope(repo, base, "HEAD", intent, ()) + + +class RootRecoverySelectionTests(unittest.TestCase): + def git(self, repo: Path, *args: str) -> bytes: + return subprocess.run( + ["git", *args], cwd=repo, check=True, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, + ).stdout.strip() + + def fixture(self, directory: str) -> tuple[Path, str, dict[str, object]]: + repo = Path(directory) + self.git(repo, "init", "-q", "-b", "main") + self.git(repo, "config", "user.email", "test@example.invalid") + self.git(repo, "config", "user.name", "Test") + (repo / "README.md").write_text("base\n") + self.git(repo, "add", ".") + self.git(repo, "commit", "-qm", "base") + base = self.git(repo, "rev-parse", "HEAD").decode() + source_contract = ( + Path(__file__).resolve().parents[1] / checker.ROOT_RECOVERY_CONTRACT + ).read_bytes() + for path in checker.ROOT_RECOVERY_PATHS: + destination = repo / path + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_text("fixture\n") + (repo / checker.ROOT_RECOVERY_CONTRACT).write_bytes(source_contract) + (repo / ".agent-loop/policies/loop-memory-recovery.json").write_text(json.dumps({ + "activation": { + "chunk_id": checker.ROOT_RECOVERY_CHUNK, + "initiative_id": checker.ROOT_RECOVERY_INITIATIVE, + }, + "signed_basis": base, + "recovered_merges": [], + "schema_version": 7, + })) + intent = { + "initiative_id": checker.ROOT_RECOVERY_INITIATIVE, + "chunk_id": checker.ROOT_RECOVERY_CHUNK, + } + self.git(repo, "add", ".") + self.git(repo, "commit", "-qm", "root recovery") + return repo, base, intent + + def scope(self, repo: Path, base: str, intent: dict[str, object]) -> checker.ScopeContract | None: + with mock.patch.object(checker, "ROOT_RECOVERY_SIGNED_BASIS", base): + return checker.root_recovery_scope(repo, base, "HEAD", intent) + + def test_exact_one_use_root_recovery_is_admitted(self) -> None: + with tempfile.TemporaryDirectory() as directory: + repo, base, intent = self.fixture(directory) + scope = self.scope(repo, base, intent) + self.assertIsNotNone(scope) + assert scope is not None + checker.enforce_scope(scope, checker.discover_changes(repo, base, "HEAD")) + + def test_root_recovery_rejects_wrong_base_and_identity(self) -> None: + with tempfile.TemporaryDirectory() as directory: + repo, base, intent = self.fixture(directory) + with self.assertRaisesRegex(checker.ContractError, "exact trusted commit"): + checker.root_recovery_scope(repo, base, "HEAD", intent) + wrong = {**intent, "initiative_id": "WS-ENG-ROOT-999"} + with self.assertRaisesRegex(checker.ContractError, "identity"): + self.scope(repo, base, wrong) + self.assertIsNone(self.scope(repo, base, {**intent, "chunk_id": "WS-ENG-ROOT-001-02"})) + + def test_root_recovery_rejects_extra_path_scope_and_reuse(self) -> None: + with tempfile.TemporaryDirectory() as directory: + repo, base, intent = self.fixture(directory) + (repo / "scripts/extra.py").write_text("extra\n") + self.git(repo, "add", ".") + self.git(repo, "commit", "-qm", "extra path") + with self.assertRaisesRegex(checker.ContractError, "exact path certificate"): + self.scope(repo, base, intent) + with tempfile.TemporaryDirectory() as directory: + repo, base, intent = self.fixture(directory) + path = repo / checker.ROOT_RECOVERY_CONTRACT + path.write_bytes(path.read_bytes().replace( + b"scripts/test_update_post_merge_memory.py", b"scripts/extra.py" + )) + self.git(repo, "add", ".") + self.git(repo, "commit", "-qm", "broaden scope") + with self.assertRaisesRegex(checker.ContractError, "contract scope"): + self.scope(repo, base, intent) + with tempfile.TemporaryDirectory() as directory: + repo, base, intent = self.fixture(directory) + policy = repo / ".agent-loop/policies/loop-memory-recovery.json" + data = json.loads(policy.read_text()) + data["recovered_merges"] = [{"merge_sha": "a" * 40}] + policy.write_text(json.dumps(data)) + self.git(repo, "add", ".") + self.git(repo, "commit", "-qm", "reuse") + with self.assertRaisesRegex(checker.ContractError, "consumed"): + self.scope(repo, base, intent) + class GitDiscoveryIntegrationTests(unittest.TestCase): def git(self, repo: Path, *args: str) -> bytes: return subprocess.run( diff --git a/scripts/test_check_loop_memory_state.py b/scripts/test_check_loop_memory_state.py index 6abe12406..297e7ff89 100644 --- a/scripts/test_check_loop_memory_state.py +++ b/scripts/test_check_loop_memory_state.py @@ -4,6 +4,7 @@ from pathlib import Path import json +import subprocess import pytest import yaml @@ -200,6 +201,138 @@ def test_checker_rejects_invalid_legacy_exemption(tmp_path: Path) -> None: assert checker._record_failures(record, "fixture") +def test_checker_independently_validates_root_recovery_evidence() -> None: + record = fixtures._record() + record["source"].update( + main_sha="f" * 40, first_parent_sha=checker.ROOT_RECOVERY_SIGNED_BASIS, + pr_number=999, + pr_url="https://github.com/Flow-Research/workstream/pull/999", + ) + record["completed_chunk"].update( + initiative_id="WS-ENG-ROOT-001", chunk_id=checker.ROOT_RECOVERY_CHUNK_ID, + chunk_title="Exact Root Recovery", + next_chunk_id=None, next_chunk_title=None, + ) + record["source"]["intent_path"] = ( + ".agent-loop/merge-intents/WS-ENG-ROOT-001-01.json" + ) + record["gate"].update(next_chunk_id=None, next_chunk_title=None) + recovery = { + "merge_sha": record["source"]["main_sha"], + "head_sha": record["source"]["head_sha"], + "chunk_id": checker.ROOT_RECOVERY_CHUNK_ID, "pr_number": 999, + "policy_schema": 7, "signed_basis": checker.ROOT_RECOVERY_SIGNED_BASIS, + "activation_chunk_id": checker.ROOT_RECOVERY_CHUNK_ID, + "certificate_sha256": checker.ROOT_RECOVERY_CERTIFICATE_SHA256, + "reason": checker.ROOT_RECOVERY_REASON, "code": checker.ROOT_RECOVERY_CODE, + } + record["protected_checks"] = { + "schema_version": 1, "recovery_only": recovery, + "sha256": checker.hashlib.sha256( + json.dumps(recovery, sort_keys=True, separators=(",", ":")).encode() + ).hexdigest(), + } + assert checker._record_failures(record, "root") == [] + record["protected_checks"]["recovery_only"]["reason"] = "widened" + assert any( + "invalid root recovery evidence" in failure + for failure in checker._record_failures(record, "root") + ) + + +@pytest.mark.parametrize("mutation", ["initiative", "parent"]) +def test_checker_binds_root_recovery_to_root_record(mutation: str) -> None: + record = fixtures._record() + record["source"].update( + main_sha="f" * 40, + first_parent_sha=checker.ROOT_RECOVERY_SIGNED_BASIS, + pr_number=999, + pr_url="https://github.com/Flow-Research/workstream/pull/999", + intent_path=f".agent-loop/merge-intents/{checker.ROOT_RECOVERY_CHUNK_ID}.json", + ) + record["completed_chunk"].update( + initiative_id="WS-ENG-ROOT-001", + chunk_id=checker.ROOT_RECOVERY_CHUNK_ID, + next_chunk_id=None, + next_chunk_title=None, + ) + record["gate"].update(next_chunk_id=None, next_chunk_title=None) + recovery = { + "merge_sha": record["source"]["main_sha"], + "head_sha": record["source"]["head_sha"], + "chunk_id": checker.ROOT_RECOVERY_CHUNK_ID, + "pr_number": 999, + "policy_schema": 7, + "signed_basis": checker.ROOT_RECOVERY_SIGNED_BASIS, + "activation_chunk_id": checker.ROOT_RECOVERY_CHUNK_ID, + "certificate_sha256": checker.ROOT_RECOVERY_CERTIFICATE_SHA256, + "reason": checker.ROOT_RECOVERY_REASON, + "code": checker.ROOT_RECOVERY_CODE, + } + record["protected_checks"] = { + "schema_version": 1, + "recovery_only": recovery, + "sha256": checker.hashlib.sha256( + json.dumps(recovery, sort_keys=True, separators=(",", ":")).encode() + ).hexdigest(), + } + if mutation == "initiative": + record["completed_chunk"].update( + initiative_id="WS-OTHER-001", chunk_id="WS-OTHER-001-01" + ) + record["source"]["intent_path"] = ".agent-loop/merge-intents/WS-OTHER-001-01.json" + else: + record["source"]["first_parent_sha"] = "a" * 40 + assert any( + "invalid root recovery evidence" in failure + for failure in checker._record_failures(record, "root") + ) + + +def test_checker_rejects_planning_intake_when_first_parent_has_prefixed_tree( + tmp_path: Path, +) -> None: + repository = tmp_path / "repo" + legacy = repository / ".agent-loop/initiatives/WS-NEW-001-existing/STATUS.md" + legacy.parent.mkdir(parents=True) + legacy.write_text("# Existing\n", encoding="utf-8") + subprocess.run(["git", "init", "-q", str(repository)], check=True) + subprocess.run(["git", "-C", str(repository), "config", "user.name", "Test"], check=True) + subprocess.run(["git", "-C", str(repository), "config", "user.email", "test@example.com"], check=True) + subprocess.run(["git", "-C", str(repository), "add", "."], check=True) + subprocess.run(["git", "-C", str(repository), "commit", "-qm", "base"], check=True) + parent = subprocess.run( + ["git", "-C", str(repository), "rev-parse", "HEAD"], + check=True, text=True, stdout=subprocess.PIPE, + ).stdout.strip() + tree = subprocess.run( + ["git", "-C", str(repository), "rev-parse", "HEAD^{tree}"], + check=True, text=True, stdout=subprocess.PIPE, + ).stdout.strip() + intake = { + "base_tree_sha": tree, + "first_parent_tree_sha": tree, + "merge_tree_sha": tree, + "changed_paths": [], + "delta_sha256": "0" * 64, + } + source = { + "first_parent_sha": parent, + "main_sha": parent, + "intent_path": ".agent-loop/merge-intents/WS-NEW-001-PLAN.json", + } + assert any( + "initiative tree already exists" in failure + for failure in checker._planning_tree_failures( + intake, source, repository, "fixture" + ) + ) + source["intent_path"] = None + assert checker._planning_tree_failures( + intake, source, repository, "fixture" + ) == ["fixture: planning intake intent path is invalid"] + + def test_explicit_event_workflow_has_closed_write_boundary() -> None: root = Path(__file__).resolve().parents[1] path = root / ".github/workflows/loop-memory-start.yml" diff --git a/scripts/test_update_post_merge_memory.py b/scripts/test_update_post_merge_memory.py index 19240bd68..cfc440ada 100644 --- a/scripts/test_update_post_merge_memory.py +++ b/scripts/test_update_post_merge_memory.py @@ -489,6 +489,135 @@ def _recovery_policy_v3() -> dict: } +def _root_recovery_policy_v7() -> dict: + return { + "schema_version": 7, + "signed_basis": loop.ROOT_RECOVERY_SIGNED_BASIS, + "activation": { + "initiative_id": loop.ROOT_RECOVERY_INITIATIVE_ID, + "chunk_id": loop.ROOT_RECOVERY_CHUNK_ID, + }, + "recovered_merges": [], + } + + +@pytest.mark.parametrize( + "path", + [ + ".agent-loop/initiatives/WS-NEW-001/STATUS.md", + ".agent-loop/initiatives/WS-NEW-001-existing/STATUS.md", + ], +) +def test_planning_intake_rejects_exact_or_prefixed_existing_tree(path: str) -> None: + entries = {path: ("100644", "blob", "a" * 40)} + assert loop._initiative_tree_exists(entries, "WS-NEW-001") + assert not loop._initiative_tree_exists(entries, "WS-OTHER-001") + + +@pytest.mark.parametrize("mutation", ["initiative", "parent"]) +def test_root_recovery_record_binds_identity_and_signed_parent(mutation: str) -> None: + record = _merge_record( + loop.ROOT_RECOVERY_INITIATIVE_ID, + loop.ROOT_RECOVERY_CHUNK_ID, + 999, + "f" * 40, + loop.ROOT_RECOVERY_SIGNED_BASIS, + ) + recovery = { + "merge_sha": record["source"]["main_sha"], + "head_sha": record["source"]["head_sha"], + "chunk_id": loop.ROOT_RECOVERY_CHUNK_ID, + "pr_number": 999, + "policy_schema": 7, + "signed_basis": loop.ROOT_RECOVERY_SIGNED_BASIS, + "activation_chunk_id": loop.ROOT_RECOVERY_CHUNK_ID, + "certificate_sha256": loop.ROOT_RECOVERY_CERTIFICATE_SHA256, + "reason": loop.ROOT_RECOVERY_REASON, + "code": loop.ROOT_RECOVERY_CODE, + } + record["protected_checks"] = { + "schema_version": 1, + "recovery_only": recovery, + "sha256": loop.hashlib.sha256( + loop._canonical_json(recovery).encode() + ).hexdigest(), + } + if mutation == "initiative": + record["completed_chunk"]["initiative_id"] = "WS-OTHER-001" + record["completed_chunk"]["chunk_id"] = "WS-OTHER-001-01" + record["source"]["intent_path"] = ".agent-loop/merge-intents/WS-OTHER-001-01.json" + else: + record["source"]["first_parent_sha"] = "a" * 40 + with pytest.raises(loop.LoopMemoryError, match="root recovery evidence"): + loop._validate_record(record) + + +def test_root_recovery_v7_is_exact_one_use_and_check_independent( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + state_root = tmp_path / "state" + base = _record() + base["source"]["main_sha"] = loop.ROOT_RECOVERY_SIGNED_BASIS + loop.apply_merge_record(state_root, base) + target_sha = "f" * 40 + target = _merge_record( + loop.ROOT_RECOVERY_INITIATIVE_ID, loop.ROOT_RECOVERY_CHUNK_ID, 999, + target_sha, loop.ROOT_RECOVERY_SIGNED_BASIS, + ) + target["checks"] = { + "required": { + name: {"kind": "missing", "conclusion": None, "url": None} + for name in loop.REQUIRED_CHECKS + }, + "all_required_passed": False, + } + policy = _root_recovery_policy_v7() + monkeypatch.setattr(loop, "_load_json_at_commit", lambda *_args: policy) + + def collect(_client, _repository, sha, **kwargs): + assert sha == target_sha + assert kwargs == {"root_recovery_policy": policy} + record = json.loads(json.dumps(target)) + recovery = { + "merge_sha": target_sha, "head_sha": record["source"]["head_sha"], + "chunk_id": loop.ROOT_RECOVERY_CHUNK_ID, "pr_number": 999, + "policy_schema": 7, "signed_basis": loop.ROOT_RECOVERY_SIGNED_BASIS, + "activation_chunk_id": loop.ROOT_RECOVERY_CHUNK_ID, + "certificate_sha256": loop.ROOT_RECOVERY_CERTIFICATE_SHA256, + "reason": loop.ROOT_RECOVERY_REASON, "code": loop.ROOT_RECOVERY_CODE, + } + record["protected_checks"] = { + "schema_version": 1, "recovery_only": recovery, + "sha256": loop.hashlib.sha256( + loop._canonical_json(recovery).encode() + ).hexdigest(), + } + return record + + monkeypatch.setattr(loop, "collect_merge_record", collect) + exemptions = loop.prepare_recovery_exemptions( + object(), "Flow-Research/workstream", repository_root=tmp_path, + state_root=state_root, target_sha=target_sha, planned_shas=[target_sha], + ) + target = collect(object(), "Flow-Research/workstream", target_sha, + root_recovery_policy=policy) + assert not target["checks"]["all_required_passed"] + assert loop.apply_merge_record(state_root, target, exemptions) + loop.assert_recovery_consumed(state_root, target_sha, exemptions) + assert loop.prepare_recovery_exemptions( + object(), "Flow-Research/workstream", repository_root=tmp_path, + state_root=state_root, target_sha=target_sha, planned_shas=[], + ) == [] + + +@pytest.mark.parametrize("field", ["signed_basis", "activation", "recovered_merges"]) +def test_root_recovery_v7_rejects_widening(field: str) -> None: + policy = _root_recovery_policy_v7() + policy[field] = "bad" if field != "recovered_merges" else [{}] + with pytest.raises(loop.LoopMemoryError): + loop._validate_recovery_policy(policy) + + @pytest.mark.parametrize( "mutation", [ diff --git a/scripts/update_post_merge_memory.py b/scripts/update_post_merge_memory.py index 379c34120..440dd2bae 100644 --- a/scripts/update_post_merge_memory.py +++ b/scripts/update_post_merge_memory.py @@ -73,6 +73,12 @@ GITHUB_ACTIONS_APP_SLUG = "github-actions" R3_RECOVERY_CERTIFICATE_SHA256 = "4fe49b2f4a5a7ad18382a717dcc11f798c465a534066102bf9810c9ed5784f4a" R3_HISTORICAL_HEAD_SHA = "55a11d9e0ae356734dbcce73564f5f570220a81b" +ROOT_RECOVERY_SIGNED_BASIS = "339248c40020658583bf7bd1e4a58daf85f5ffb8" +ROOT_RECOVERY_INITIATIVE_ID = "WS-ENG-ROOT-001" +ROOT_RECOVERY_CHUNK_ID = "WS-ENG-ROOT-001-01" +ROOT_RECOVERY_REASON = "planning-intake-gate-circularity" +ROOT_RECOVERY_CODE = "exact-root-gate-repair-v1" +ROOT_RECOVERY_CERTIFICATE_SHA256 = "32f75b9709e6b09b30e672cc9889a8754dad1428e0b57e06d6bd237ae6476e40" CHECK_RUN_CONCLUSIONS = frozenset({ "action_required", "cancelled", "failure", "neutral", "skipped", "stale", "success", "timed_out", @@ -1017,6 +1023,17 @@ def _planning_chunk_name_matches(filename: str, initiative_id: str) -> bool: return bool(pattern.fullmatch(filename)) +def _initiative_tree_exists( + entries: dict[str, tuple[str, str, str]], initiative_id: str +) -> bool: + """Return whether a canonical exact or slugged initiative tree exists.""" + prefixes = ( + f"{CHUNK_CONTRACT_ROOT}{initiative_id}/", + f"{CHUNK_CONTRACT_ROOT}{initiative_id}-", + ) + return any(path.startswith(prefixes) for path in entries) + + def _collect_planning_intake( client: GitHubClient, repository: str, @@ -1133,6 +1150,11 @@ def _collect_planning_intake( client, repository, first_parent_tree, "first parent" ) merge_entries = _tree_entries(client, repository, merge_tree, "merge") + if any( + _initiative_tree_exists(entries, metadata.initiative_id) + for entries in (base_entries, first_parent_entries) + ): + raise LoopMemoryError("planning intake initiative tree already exists") reviewed_delta = _tree_delta(base_entries, head_entries) merged_delta = _tree_delta(first_parent_entries, merge_entries) if reviewed_delta != merged_delta or sorted(reviewed_delta) != sorted(changed_paths): @@ -1164,6 +1186,7 @@ def collect_merge_record( merge_sha: str, *, historical_recovery: bool = False, + root_recovery_policy: dict[str, Any] | None = None, ) -> dict[str, Any]: """Collect one exact merged PR and its bounded loop metadata from GitHub.""" _validate_repository_and_sha(repository, merge_sha) @@ -1254,7 +1277,25 @@ def collect_merge_record( "merged pull request URL does not match repository and number" ) - if historical_recovery: + if root_recovery_policy is not None: + recovery_only = { + "merge_sha": merge_sha, "head_sha": head_sha, + "chunk_id": metadata.chunk_id, "pr_number": pr_number, + "policy_schema": 7, + "signed_basis": ROOT_RECOVERY_SIGNED_BASIS, + "activation_chunk_id": ROOT_RECOVERY_CHUNK_ID, + "certificate_sha256": hashlib.sha256( + _canonical_json(root_recovery_policy).encode("utf-8") + ).hexdigest(), + "reason": ROOT_RECOVERY_REASON, "code": ROOT_RECOVERY_CODE, + } + protected_checks = { + "schema_version": 1, "recovery_only": recovery_only, + "sha256": hashlib.sha256( + _canonical_json(recovery_only).encode("utf-8") + ).hexdigest(), + } + elif historical_recovery: recovery_only = { "merge_sha": merge_sha, "head_sha": head_sha, "chunk_id": metadata.chunk_id, @@ -1302,7 +1343,7 @@ def collect_merge_record( }, "checks": _check_evidence(check_runs, statuses, merged_at), } - if historical_recovery or complete_check_client: + if root_recovery_policy is not None or historical_recovery or complete_check_client: record["protected_checks"] = protected_checks planning_intake = _collect_planning_intake( client, @@ -2116,6 +2157,27 @@ def _validate_record(record: dict[str, Any]) -> LoopMetadata: raise LoopMemoryError("protected check evidence has an invalid schema") if set(protected) == {"schema_version", "recovery_only", "sha256"}: recovery_only = protected.get("recovery_only") + if isinstance(recovery_only, dict) and recovery_only.get("policy_schema") == 7: + expected = { + "merge_sha": source["main_sha"], "head_sha": source["head_sha"], + "chunk_id": ROOT_RECOVERY_CHUNK_ID, "pr_number": source["pr_number"], + "policy_schema": 7, "signed_basis": ROOT_RECOVERY_SIGNED_BASIS, + "activation_chunk_id": ROOT_RECOVERY_CHUNK_ID, + "certificate_sha256": ROOT_RECOVERY_CERTIFICATE_SHA256, + "reason": ROOT_RECOVERY_REASON, "code": ROOT_RECOVERY_CODE, + } + if ( + recovery_only != expected + or protected.get("sha256") != hashlib.sha256(_canonical_json(expected).encode("utf-8")).hexdigest() + or metadata.initiative_id != ROOT_RECOVERY_INITIATIVE_ID + or metadata.chunk_id != ROOT_RECOVERY_CHUNK_ID + or metadata.next_chunk_id is not None + or metadata.next_chunk_title is not None + or source.get("intent_path") != f".agent-loop/merge-intents/{ROOT_RECOVERY_CHUNK_ID}.json" + or source.get("first_parent_sha") != ROOT_RECOVERY_SIGNED_BASIS + ): + raise LoopMemoryError("root recovery evidence is invalid") + return metadata expected = { "merge_sha": "d3321698fb856f3fac320cdc7bc598f813fe1953", "head_sha": R3_HISTORICAL_HEAD_SHA, "chunk_id": "WS-ENG-007-00R2", @@ -2293,11 +2355,12 @@ def _validate_recovery_policy(payload: Any) -> dict[str, Any]: 4: {"schema_version", "signed_basis", "activation", "recovered_merges"}, 5: {"schema_version", "signed_basis", "activation", "recovered_merges"}, 6: {"schema_version", "signed_basis", "activation", "recovered_merges"}, + 7: {"schema_version", "signed_basis", "activation", "recovered_merges"}, }.get(version, set()) if set(payload) != expected: raise LoopMemoryError("recovery policy has an invalid schema") activation = payload.get("activation") - if version not in {1, 2, 3, 4, 5, 6} or not isinstance(activation, dict): + if version not in {1, 2, 3, 4, 5, 6, 7} or not isinstance(activation, dict): raise LoopMemoryError("recovery policy is unsupported") if set(activation) != {"initiative_id", "chunk_id"} or not _is_valid_exemption_id( activation.get("initiative_id"), activation.get("chunk_id") @@ -2307,6 +2370,17 @@ def _validate_recovery_policy(payload: Any) -> dict[str, Any]: if payload.get("mode") != "exact_single_target": raise LoopMemoryError("recovery policy mode is unsupported") return json.loads(_canonical_json(payload)) + if version == 7: + if ( + payload.get("signed_basis") != ROOT_RECOVERY_SIGNED_BASIS + or activation != { + "initiative_id": ROOT_RECOVERY_INITIATIVE_ID, + "chunk_id": ROOT_RECOVERY_CHUNK_ID, + } + or payload.get("recovered_merges") != [] + ): + raise LoopMemoryError("root recovery certificate is not exact") + return json.loads(_canonical_json(payload)) if version in {3, 4, 5, 6}: recovered_merges = payload.get("recovered_merges") valid_length = ( @@ -2405,7 +2479,13 @@ def prepare_recovery_exemptions( repository_root, target_sha, RECOVERY_POLICY_PATH, "recovery policy" ) ) - target_record = collect_merge_record(client, repository, target_sha) + target_record = ( + collect_merge_record( + client, repository, target_sha, root_recovery_policy=policy + ) + if policy["schema_version"] == 7 + else collect_merge_record(client, repository, target_sha) + ) activation = policy["activation"] target_identity = _record_exemption(target_record) if ( @@ -2433,6 +2513,27 @@ def prepare_recovery_exemptions( if not isinstance(existing, list) or exemption in existing: raise LoopMemoryError("recovery exemption collides with signed state") return [exemption] + if policy["schema_version"] == 7: + signed_main = ( + state.get("event", {}).get("main_sha") + if _event_type(state) in {"start", "cancel"} + else state.get("source", {}).get("main_sha") + ) + if signed_main != policy["signed_basis"]: + raise LoopMemoryError("root recovery signed basis does not match canonical state") + if planned_shas != [target_sha]: + raise LoopMemoryError("root recovery plan is not exact") + source = target_record.get("source", {}) + if source.get("main_sha") != target_sha or source.get("first_parent_sha") != signed_main: + raise LoopMemoryError("root recovery target is not the signed first parent") + protected = target_record.get("protected_checks", {}) + if protected.get("recovery_only", {}).get("policy_schema") != 7: + raise LoopMemoryError("root recovery protected evidence is missing") + exemption = target_identity + existing = state.get("legacy_exemptions", []) + if not isinstance(existing, list) or exemption in existing: + raise LoopMemoryError("recovery exemption collides with signed state") + return [exemption] if policy["schema_version"] in {3, 4, 5, 6}: recovered_policies = policy["recovered_merges"] expected_shas = [item["merge_sha"] for item in recovered_policies] + [target_sha]