From c8fa15ca7f48d1369c24044349ebfacbac88afbd Mon Sep 17 00:00:00 2001 From: Atelier Protocol Experiment Date: Wed, 29 Jul 2026 17:03:19 -0700 Subject: [PATCH] fix(claiming): preserve inherited PR candidates Preserve exact published candidates, including authorized pull request metadata, across blocked delivery, release, fresh-clone reconstruction, and reclaim. Validate terminal pull request identity and add end-to-end contract coverage for the inherited candidate lifecycle. --- contract_tests/test_claiming.py | 604 +++++++++++++++++- contract_tests/test_git_mailbox.py | 194 +++++- contract_tests/test_mailbox.py | 43 +- docs/git-mailbox-contract.md | 65 +- experiments/mailbox_protocol_v0.py | 2 + .../atelier/references/mailbox-v1.schema.json | 16 + skills/atelier/scripts/claiming.py | 86 ++- skills/atelier/scripts/delegation.py | 51 +- skills/atelier/scripts/git_mailbox.py | 28 +- skills/atelier/scripts/mailbox.py | 188 +++++- 10 files changed, 1227 insertions(+), 50 deletions(-) diff --git a/contract_tests/test_claiming.py b/contract_tests/test_claiming.py index 8e4b5d26..abf3c71c 100644 --- a/contract_tests/test_claiming.py +++ b/contract_tests/test_claiming.py @@ -50,7 +50,11 @@ TransitionPlan, run_git, ) -from skills.atelier.scripts.mailbox import _read_yaml, reconstruct_mailbox +from skills.atelier.scripts.mailbox import ( + MailboxValidationError, + _read_yaml, + reconstruct_mailbox, +) from skills.atelier.scripts.planning import ( ApprovalEnvelope, AssignmentDraft, @@ -1012,6 +1016,188 @@ def test_candidate_publication_requires_paired_push_and_exact_remote_reachabilit ["pre_external_mutation", "candidate_published"], ) + def _authorize_initial_pull_request(self): + claimed = self.claim() + initial_push = self.checkpoint( + claimed, + action="repository.candidate.push", + token="token-1", + candidate_head=HEAD, + ) + initial_publication = self.checkpoint( + initial_push, + action="repository.candidate.push", + token="token-2", + candidate_head=HEAD, + phase="candidate_published", + candidate=self.candidate(), + ) + authority = self.checkpoint( + initial_publication, + action="pull_request.create", + token="token-3", + candidate_head=HEAD, + ) + candidate = self.candidate() + candidate["pull_request"] = "https://github.com/example/project-1/pull/1" + return authority, candidate + + def test_candidate_publication_retains_pr_across_same_lineage_head_advance(self) -> None: + self.coordinator.candidate_verifier = lambda candidate: True + pull_request_authority, candidate_with_pull_request = ( + self._authorize_initial_pull_request() + ) + repush = self.checkpoint( + pull_request_authority, + action="repository.candidate.push", + token="token-4", + candidate_head=HEAD, + ) + published_pull_request = self.checkpoint( + repush, + action="repository.candidate.push", + token="token-5", + candidate_head=HEAD, + phase="candidate_published", + candidate=candidate_with_pull_request, + ) + next_head = "d" * 40 + next_push = self.checkpoint( + published_pull_request, + action="repository.candidate.push", + token="token-6", + candidate_head=next_head, + ) + next_candidate = candidate_with_pull_request.copy() + next_candidate["head_revision"] = next_head + + published_next_head = self.checkpoint( + next_push, + action="repository.candidate.push", + token="token-7", + candidate_head=next_head, + phase="candidate_published", + candidate=next_candidate, + ) + + self.assertEqual(published_next_head.sequence, 7) + checkout = self.mailbox_clone("retained-pr-read") + work, _ = _read_yaml( + checkout / f"work/{self.work_id}/work.md", + frontmatter=True, + label="work", + ) + self.assertEqual(work["claim"]["candidate"]["head_revision"], next_head) + self.assertEqual( + work["claim"]["candidate"]["pull_request"], + candidate_with_pull_request["pull_request"], + ) + + def test_candidate_publication_rejects_pr_introduced_with_prior_head_authority( + self, + ) -> None: + self.coordinator.candidate_verifier = lambda candidate: True + pull_request_authority, candidate_with_pull_request = ( + self._authorize_initial_pull_request() + ) + next_head = "d" * 40 + next_push = self.checkpoint( + pull_request_authority, + action="repository.candidate.push", + token="token-4", + candidate_head=next_head, + ) + next_candidate = candidate_with_pull_request.copy() + next_candidate["head_revision"] = next_head + + with self.assertRaisesRegex( + MailboxTransitionRejected, + "candidate pull request metadata lacks exact pre-mutation authority", + ): + self.checkpoint( + next_push, + action="repository.candidate.push", + token="token-5", + candidate_head=next_head, + phase="candidate_published", + candidate=next_candidate, + ) + + def test_pull_request_url_change_consumes_fresh_mutation_authority(self) -> None: + pull_request_authority, first_candidate = self._authorize_initial_pull_request() + repush = self.checkpoint( + pull_request_authority, + action="repository.candidate.push", + token="token-4", + candidate_head=HEAD, + ) + first_publication = self.checkpoint( + repush, + action="repository.candidate.push", + token="token-5", + candidate_head=HEAD, + phase="candidate_published", + candidate=first_candidate, + ) + stale_push = self.checkpoint( + first_publication, + action="repository.candidate.push", + token="token-6", + candidate_head=HEAD, + ) + replacement_candidate = first_candidate.copy() + replacement_candidate["pull_request"] = "https://github.com/example/project-1/pull/999" + + with self.assertRaisesRegex( + MailboxTransitionRejected, + "candidate pull request metadata lacks exact pre-mutation authority", + ): + self.checkpoint( + stale_push, + action="repository.candidate.push", + token="stale-token-7", + candidate_head=HEAD, + phase="candidate_published", + candidate=replacement_candidate, + ) + + fresh_authority = self.checkpoint( + stale_push, + action="pull_request.update", + token="token-7", + candidate_head=HEAD, + ) + replacement_push = self.checkpoint( + fresh_authority, + action="repository.candidate.push", + token="token-8", + candidate_head=HEAD, + ) + replacement_publication = self.checkpoint( + replacement_push, + action="repository.candidate.push", + token="token-9", + candidate_head=HEAD, + phase="candidate_published", + candidate=replacement_candidate, + ) + + self.assertEqual(replacement_publication.sequence, 9) + checkout = self.mailbox_clone("replacement-pr-read") + work, _ = _read_yaml( + checkout / f"work/{self.work_id}/work.md", + frontmatter=True, + label="work", + ) + self.assertEqual( + [ + entry["candidate_pull_request"] + for entry in work["claim"]["checkpoint"]["authorizations"] + if entry["phase"] == "candidate_published" + ], + [None, first_candidate["pull_request"], replacement_candidate["pull_request"]], + ) + def test_default_policy_remote_verifier_binds_github_repository_identity(self) -> None: git( self.project_checkout, @@ -2232,6 +2418,422 @@ def test_blocked_result_replaces_older_candidate_with_latest_verified_push(self) self.assertEqual(work["claim"]["candidate"]["head_revision"], latest_head) reconstruct_mailbox(checkout) + def test_inherited_candidate_survives_pr_block_release_reclaim_and_delivery(self) -> None: + def fresh_coordinator() -> ClaimCoordinator: + return ClaimCoordinator( + str(self.mailbox_remote), + "main", + candidate_verifier=lambda value: _candidate_remote_reachable( + {**value, "remote_url": str(self.project_remote)} + ), + capability_verifier=lambda target: True, + policy_remote_verifier=self.policy_remote_matches, + ) + + initial_claim = self.claim() + self.coordinator = fresh_coordinator() + initial_delegation = self.delegation() + initial_invocation = self.delegated_invocation(initial_claim) + candidate_head = git(self.project_checkout, "rev-parse", "HEAD").stdout.strip() + candidate_ref = "refs/heads/scott/pr-recovery-candidate" + git(self.project_checkout, "push", "origin", f"{candidate_head}:{candidate_ref}") + candidate = { + "repository": initial_invocation["repository"]["identity"], + "remote_url": initial_invocation["repository"]["remote_url"], + "remote_ref": candidate_ref, + "base_sha": initial_invocation["repository"]["base_sha"], + "head_sha": candidate_head, + } + initial_push = self.delegated_checkpoint( + initial_delegation, + initial_invocation, + initial_claim, + action="repository.candidate.push", + token="initial-token-1", + candidate=candidate, + ) + published = self.delegated_checkpoint( + initial_delegation, + initial_invocation, + initial_push, + action="repository.candidate.push", + token="initial-token-2", + phase="candidate_published", + candidate=candidate, + ) + old_pull_request_url = "https://github.com/example/project-1/pull/793" + old_terminal_candidate = { + **candidate, + "publication": { + "kind": "ordinary", + "pull_requests": [ + { + "id": "793", + "url": old_pull_request_url, + "base_ref": initial_invocation["repository"]["base_ref"], + "base_sha": candidate["base_sha"], + "head_ref": candidate["remote_ref"], + "head_sha": candidate["head_sha"], + "state": "open", + } + ], + }, + } + initial_pr_authorized = self.delegated_checkpoint( + initial_delegation, + initial_invocation, + published, + action="pull_request.create", + token="initial-token-3", + candidate=candidate, + ) + initial_repush = self.delegated_checkpoint( + initial_delegation, + initial_invocation, + initial_pr_authorized, + action="repository.candidate.push", + token="initial-token-4", + candidate=candidate, + ) + initial_pr_published = self.delegated_checkpoint( + initial_delegation, + initial_invocation, + initial_repush, + action="repository.candidate.push", + token="initial-token-5", + phase="candidate_published", + candidate=old_terminal_candidate, + ) + first_release_id = new_identifier("rcp") + self.coordinator.release( + self.work_id, + self.fence(initial_pr_published), + receipt_id=first_release_id, + reason="Transfer the first PR-bearing candidate to a fresh worker.", + ended_at=OBSERVED_AT + timedelta(minutes=6), + ) + + self.coordinator = fresh_coordinator() + reclaimed = self.claim(token="pr-recovery-token-0") + delegation = self.delegation() + invocation = self.delegated_invocation(reclaimed) + self.assertEqual(invocation["repository"]["base_sha"], candidate["base_sha"]) + pull_request_url = "https://github.com/example/project-1/pull/794" + terminal_candidate = { + **candidate, + "publication": { + "kind": "ordinary", + "pull_requests": [ + { + "id": "794", + "url": pull_request_url, + "base_ref": invocation["repository"]["base_ref"], + "base_sha": candidate["base_sha"], + "head_ref": candidate["remote_ref"], + "head_sha": candidate["head_sha"], + "state": "open", + } + ], + }, + } + pr_authorized = self.delegated_checkpoint( + delegation, + invocation, + reclaimed, + action="pull_request.update", + token="pr-recovery-token-1", + candidate=candidate, + ) + push_authorized = self.delegated_checkpoint( + delegation, + invocation, + pr_authorized, + action="repository.candidate.push", + token="pr-recovery-token-2", + candidate=candidate, + ) + pr_acknowledged = self.delegated_checkpoint( + delegation, + invocation, + push_authorized, + action="repository.candidate.push", + token="pr-recovery-token-3", + phase="candidate_published", + candidate=terminal_candidate, + ) + blocked_result = self.blocked_result( + invocation, + pr_acknowledged, + candidate=terminal_candidate, + authority_used=["pull_request.update", "repository.candidate.push"], + ) + blocked_result["blocking_reason"] = "A later planner decision is still required." + delegation.finalize( + self.work_id, + invocation, + blocked_result, + self.fence(pr_acknowledged), + approved_commit=self.approved_commit, + policy_target=self.policy_target(), + host_target=self.delegation_host_target(), + observation_path=self.observation_path, + observation_not_before=self.live_at, + ended_at=self.live_at + timedelta(minutes=3), + now=self.live_at + timedelta(minutes=3), + ) + + blocked_checkout = self.mailbox_clone("pr-recovery-blocked-read") + blocked_work, _ = _read_yaml( + blocked_checkout / f"work/{self.work_id}/work.md", + frontmatter=True, + label="PR-bearing blocked work", + ) + reconstruct_mailbox(blocked_checkout) + self.assertEqual(blocked_work["claim"]["candidate"]["pull_request"], pull_request_url) + + release_id = new_identifier("rcp") + self.coordinator.release( + self.work_id, + self.fence(pr_acknowledged), + receipt_id=release_id, + reason="Transfer the blocked PR-bearing candidate before a head advance.", + ended_at=OBSERVED_AT + timedelta(minutes=6), + ) + + self.coordinator = fresh_coordinator() + advanced_claim = self.claim(token="head-advance-token-0") + advanced_delegation = self.delegation() + advanced_invocation = self.delegated_invocation(advanced_claim) + git( + self.project_checkout, + "-c", + "user.name=Atelier Test", + "-c", + "user.email=atelier-test@invalid", + "commit", + "--allow-empty", + "-m", + "Advance inherited candidate", + ) + advanced_head = git(self.project_checkout, "rev-parse", "HEAD").stdout.strip() + git(self.project_checkout, "push", "origin", f"{advanced_head}:{candidate_ref}") + advanced_candidate = {**candidate, "head_sha": advanced_head} + advanced_terminal_candidate = { + **advanced_candidate, + "publication": { + "kind": "ordinary", + "pull_requests": [ + { + "id": "794", + "url": pull_request_url, + "base_ref": advanced_invocation["repository"]["base_ref"], + "base_sha": advanced_candidate["base_sha"], + "head_ref": advanced_candidate["remote_ref"], + "head_sha": advanced_head, + "state": "open", + } + ], + }, + } + advanced_push = self.delegated_checkpoint( + advanced_delegation, + advanced_invocation, + advanced_claim, + action="repository.candidate.push", + token="head-advance-token-1", + candidate=advanced_candidate, + ) + advanced_published = self.delegated_checkpoint( + advanced_delegation, + advanced_invocation, + advanced_push, + action="repository.candidate.push", + token="head-advance-token-2", + phase="candidate_published", + candidate=advanced_terminal_candidate, + ) + advanced_blocked_result = self.blocked_result( + advanced_invocation, + advanced_published, + candidate=advanced_terminal_candidate, + authority_used=["repository.candidate.push"], + ) + advanced_blocked_result["blocking_reason"] = ( + "The same pull request remains blocked after its candidate head advanced." + ) + advanced_delegation.finalize( + self.work_id, + advanced_invocation, + advanced_blocked_result, + self.fence(advanced_published), + approved_commit=self.approved_commit, + policy_target=self.policy_target(), + host_target=self.delegation_host_target(), + observation_path=self.observation_path, + observation_not_before=self.live_at, + ended_at=self.live_at + timedelta(minutes=3), + now=self.live_at + timedelta(minutes=3), + ) + + advanced_checkout = self.mailbox_clone("same-pr-advanced-head-blocked-read") + advanced_work, _ = _read_yaml( + advanced_checkout / f"work/{self.work_id}/work.md", + frontmatter=True, + label="same-PR advanced-head blocked work", + ) + reconstruct_mailbox(advanced_checkout) + self.assertEqual( + advanced_work["claim"]["candidate"], + { + "repository": candidate["repository"], + "remote": "origin", + "remote_url": candidate["remote_url"], + "remote_ref": candidate_ref, + "base_revision": candidate["base_sha"], + "head_revision": advanced_head, + "pull_request": pull_request_url, + "workspace_id": None, + "published_at": (self.live_at + timedelta(minutes=2)) + .isoformat() + .replace("+00:00", "Z"), + }, + ) + self.assertEqual(advanced_work["claim"]["inherited_receipt_id"], release_id) + + advanced_work["claim"]["inherited_receipt_id"] = first_release_id + fixtures.write_markdown( + advanced_checkout / f"work/{self.work_id}/work.md", + advanced_work, + ) + with self.assertRaisesRegex(MailboxValidationError, "checkpoint-pr-authority"): + reconstruct_mailbox(advanced_checkout) + + advanced_release_id = new_identifier("rcp") + self.coordinator.release( + self.work_id, + self.fence(advanced_published), + receipt_id=advanced_release_id, + reason="Transfer the same-PR advanced candidate to a final worker.", + ended_at=OBSERVED_AT + timedelta(minutes=8), + ) + self.coordinator = fresh_coordinator() + final_claim = self.claim(token="ready-pr-token-0") + final_delegation = self.delegation() + final_invocation = self.delegated_invocation(final_claim) + replacement_pull_request_url = "https://github.com/example/project-1/pull/795" + replacement_terminal_candidate = json.loads(json.dumps(advanced_terminal_candidate)) + replacement_pull_request = replacement_terminal_candidate["publication"]["pull_requests"][0] + replacement_pull_request["id"] = "795" + replacement_pull_request["url"] = replacement_pull_request_url + replacement_authorized = self.delegated_checkpoint( + final_delegation, + final_invocation, + final_claim, + action="pull_request.update", + token="ready-pr-token-1", + candidate=advanced_candidate, + ) + + live = observation(with_pull_request=True) + live["observed_at"] = ( + (self.live_at + timedelta(minutes=9)).isoformat().replace("+00:00", "Z") + ) + live["pull_request"]["url"] = replacement_pull_request_url + live["pull_request"]["base"].update( + ref=final_invocation["repository"]["base_ref"], sha=advanced_candidate["base_sha"] + ) + live["pull_request"]["head"].update( + ref=advanced_candidate["remote_ref"], sha=advanced_head + ) + write_json(self.observation_path, live) + ready_result = { + "schema": RESULT_SCHEMA, + "capability": CAPABILITY, + "invocation_id": final_invocation["invocation_id"], + "terminal_state": "ready_pr", + "ticket": final_invocation["ticket"], + "repository": { + "identity": final_invocation["repository"]["identity"], + "base_ref": final_invocation["repository"]["base_ref"], + "base_sha": final_invocation["repository"]["base_sha"], + }, + "tracker_transition": { + "provider": final_invocation["ticket"]["provider"], + "ticket_id": final_invocation["ticket"]["id"], + "mode": "none", + "state": "open", + "observed_at": fixtures.TIMESTAMP, + }, + "implementation_state": "published", + "candidate": replacement_terminal_candidate, + "handoff": {"transferable": True, "reason": None}, + "checkpoint": { + "last_sequence": replacement_authorized.sequence, + "continuation_token": replacement_authorized.continuation_token, + }, + "validation": [ + { + "name": command, + "outcome": "passed", + "candidate_sha": advanced_head, + "observed_at": fixtures.TIMESTAMP, + } + for command in final_invocation["validation"] + ], + "reviews": [ + { + "name": "review-code-change", + "outcome": "passed", + "candidate_sha": advanced_head, + "observed_at": fixtures.TIMESTAMP, + } + ], + "feedback": { + "unresolved_material_count": 0, + "candidate_sha": advanced_head, + "observed_at": fixtures.TIMESTAMP, + }, + "authority_used": ["pull_request.update"], + "acceptance_evidence": self.acceptance_records(final_invocation, advanced_head), + "unresolved_obligations": [], + "blocking_reason": None, + "next_action": "Atelier validates the inherited ready pull request.", + } + final_delegation.finalize( + self.work_id, + final_invocation, + ready_result, + self.fence(replacement_authorized), + approved_commit=self.approved_commit, + policy_target=self.policy_target(), + host_target=self.delegation_host_target(), + observation_path=self.observation_path, + observation_not_before=self.live_at + timedelta(minutes=9), + ended_at=self.live_at + timedelta(minutes=10), + now=self.live_at + timedelta(minutes=10), + ) + + delivered_checkout = self.mailbox_clone("pr-recovery-delivered-read") + delivered_work, _ = _read_yaml( + delivered_checkout / f"work/{self.work_id}/work.md", + frontmatter=True, + label="reclaimed delivered work", + ) + reconstruct_mailbox(delivered_checkout) + self.assertEqual(delivered_work["status"], "delivered") + self.assertEqual(delivered_work["claim"]["candidate"]["head_revision"], advanced_head) + self.assertEqual( + delivered_work["claim"]["candidate"]["pull_request"], + replacement_pull_request_url, + ) + self.assertEqual( + [ + (entry["phase"], entry["action"]) + for entry in delivered_work["claim"]["checkpoint"]["authorizations"] + ], + [("pre_external_mutation", "pull_request.update")], + ) + def test_delegation_delivers_one_exact_ready_pull_request(self) -> None: claimed = self.claim() self.coordinator = ClaimCoordinator( diff --git a/contract_tests/test_git_mailbox.py b/contract_tests/test_git_mailbox.py index 2f1516dd..5a7bf3b3 100644 --- a/contract_tests/test_git_mailbox.py +++ b/contract_tests/test_git_mailbox.py @@ -50,6 +50,26 @@ def read_markdown(path: Path) -> dict[str, Any]: return value +def claim_with_published_pull_request( + repository: str, + number: int, +) -> dict[str, Any]: + claim_value = fixtures.claim(repository, number, with_candidate=True) + candidate = claim_value["candidate"] + if candidate is None: + raise AssertionError("published PR fixture requires a candidate") + authorizations = claim_value["checkpoint"]["authorizations"] + push_after_pr = copy.deepcopy(authorizations[0]) + push_after_pr["sequence"] = 4 + publish_after_pr = copy.deepcopy(authorizations[1]) + publish_after_pr["sequence"] = 5 + publish_after_pr["candidate_pull_request"] = candidate["pull_request"] + authorizations.extend((push_after_pr, publish_after_pr)) + claim_value["checkpoint"]["sequence"] = 5 + claim_value["checkpoint"]["continuation_token"] = f"token-{number}-5" + return claim_value + + def planner_message(work_id: str, number: int) -> tuple[str, str]: message_id = fixtures.identifier("msg", number) value = { @@ -768,6 +788,7 @@ def authorize(context: TransitionContext) -> TransitionPlan: "proposed_effect_digest": fixtures.DIGEST, "candidate_head": None, "candidate_remote_ref": None, + "candidate_pull_request": None, "acknowledged_candidate_head": None, "recorded_at": fixtures.TIMESTAMP, } @@ -828,6 +849,7 @@ def release(context: TransitionContext) -> TransitionPlan: adopted_claim = fixtures.claim(self.repository, 2, with_candidate=False) adopted_claim["candidate"] = copy.deepcopy(candidate) + adopted_claim["inherited_receipt_id"] = receipt_id def adopt(context: TransitionContext) -> TransitionPlan: work = read_markdown(context.checkout / work_path) @@ -849,15 +871,52 @@ def adopt(context: TransitionContext) -> TransitionPlan: plan=adopt, ) + takeover_receipt_id = fixtures.identifier("rcp", 2) + takeover_message_id = fixtures.identifier("msg", 2) takeover_claim = fixtures.claim(self.repository, 3, with_candidate=False) takeover_claim["candidate"] = copy.deepcopy(candidate) + takeover_claim["inherited_receipt_id"] = takeover_receipt_id def take_over(context: TransitionContext) -> TransitionPlan: work = read_markdown(context.checkout / work_path) + taken_over = fixtures.receipt( + work, + self.repository, + 2, + outcome="released", + with_candidate=True, + ) + taken_over["candidate"] = copy.deepcopy(candidate) + taken_over["mutation_ownership"] = "relinquished" + takeover_message = { + "schema": "atelier.message/v1", + "id": takeover_message_id, + "work_id": self.work_id, + "kind": "notification", + "author_role": "planner", + "worker_run_id": None, + "audience": "worker", + "in_reply_to": None, + "resolves": None, + "blocks": None, + "created_at": fixtures.TIMESTAMP, + "subject": "Claim taken over", + } + work["attempt_receipt_id"] = takeover_receipt_id work["claim"] = copy.deepcopy(takeover_claim) return TransitionPlan( "take over candidate handoff", - (FileChange(work_path, markdown(work)),), + ( + FileChange(work_path, markdown(work)), + FileChange( + f"work/{self.work_id}/receipts/{takeover_receipt_id}.md", + markdown(taken_over), + ), + FileChange( + f"work/{self.work_id}/messages/{takeover_message_id}.md", + markdown(takeover_message), + ), + ), ) self.writer().publish( @@ -871,7 +930,8 @@ def take_over(context: TransitionContext) -> TransitionPlan: current = read_markdown(fresh / work_path) released = read_markdown(fresh / f"work/{self.work_id}/receipts/{receipt_id}.md") self.assertEqual(released["candidate"], candidate) - self.assertEqual(current["attempt_receipt_id"], receipt_id) + self.assertEqual(current["attempt_receipt_id"], takeover_receipt_id) + self.assertEqual(current["claim"]["inherited_receipt_id"], takeover_receipt_id) self.assertEqual(current["claim"]["candidate"], candidate) self.assertEqual(current["claim"]["id"], takeover_claim["id"]) fixtures.MAILBOX.reconstruct_mailbox(fresh) @@ -882,6 +942,7 @@ def test_release_and_new_claim_require_distinct_transitions(self) -> None: replacement_claim["candidate"] = copy.deepcopy(original_claim["candidate"]) work_path = f"work/{self.work_id}/work.md" receipt_id = fixtures.identifier("rcp", 1) + replacement_claim["inherited_receipt_id"] = receipt_id before = self.remote_head() def compound_release_and_claim(context: TransitionContext) -> TransitionPlan: @@ -1298,6 +1359,7 @@ def mutate_checkpoint( if mutation == "truncate": checkpoint["authorizations"] = checkpoint["authorizations"][:-1] checkpoint["sequence"] -= 1 + work["claim"]["candidate"]["pull_request"] = None else: checkpoint["authorizations"][-1]["proposed_effect_digest"] = "sha256:" + ("f" * 64) return TransitionPlan( @@ -1323,7 +1385,7 @@ def mutate_checkpoint( def test_same_claim_cannot_rebind_authority_or_batch_checkpoints(self) -> None: self._claim(with_candidate=False) - batched_claim = fixtures.claim(self.repository, 1, with_candidate=True) + batched_claim = claim_with_published_pull_request(self.repository, 1) before = self.remote_head() def mutate_claim( @@ -1415,7 +1477,7 @@ def drop_candidate(context: TransitionContext) -> TransitionPlan: def test_new_claim_requires_empty_checkpoint_and_fresh_identities(self) -> None: path = f"work/{self.work_id}/work.md" - prepopulated = fixtures.claim(self.repository, 1, with_candidate=True) + prepopulated = claim_with_published_pull_request(self.repository, 1) before = self.remote_head() def install_prepopulated(context: TransitionContext) -> TransitionPlan: @@ -1577,6 +1639,119 @@ def rebind_candidate(context: TransitionContext) -> TransitionPlan: ) self.assertEqual(self.remote_head(), before) + def test_delivery_pr_binding_requires_fresh_exact_authorization_and_receipt(self) -> None: + published_claim = self._claim(with_candidate=True) + checkout = self.root / "delivery-pr-binding" + git(None, "clone", str(self.remote), str(checkout)) + work_path = f"work/{self.work_id}/work.md" + work = read_markdown(checkout / work_path) + published_candidate = copy.deepcopy(published_claim["candidate"]) + replacement_candidate = copy.deepcopy(published_candidate) + replacement_candidate["pull_request"] = ( + f"https://github.com/{self.repository.removeprefix('github:')}/pull/2" + ) + + authorized_claim = copy.deepcopy(published_claim) + authorization = copy.deepcopy(authorized_claim["checkpoint"]["authorizations"][2]) + authorization.update( + sequence=authorized_claim["checkpoint"]["sequence"] + 1, + action="pull_request.update", + candidate_head=replacement_candidate["head_revision"], + candidate_remote_ref=replacement_candidate["remote_ref"], + ) + authorized_claim["checkpoint"]["authorizations"].append(authorization) + authorized_claim["checkpoint"]["sequence"] = authorization["sequence"] + authorized_claim["checkpoint"]["continuation_token"] = "delivery-pr-binding-token" + delivered_claim = copy.deepcopy(authorized_claim) + delivered_claim["candidate"] = replacement_candidate + + receipt_id = fixtures.identifier("rcp", 1) + receipt_path = f"work/{self.work_id}/receipts/{receipt_id}.md" + delivered_work = copy.deepcopy(work) + delivered_work["status"] = "delivered" + delivered_work["claim"] = delivered_claim + delivered_work["attempt_receipt_id"] = receipt_id + delivered_work["delivery_receipt_id"] = receipt_id + delivered_receipt = fixtures.receipt( + delivered_work, + self.repository, + 1, + outcome="delivered", + with_candidate=True, + ) + delivered_receipt["candidate"] = copy.deepcopy(replacement_candidate) + fixtures.write_markdown(checkout / receipt_path, delivered_receipt) + receipt_change = (FileChange(receipt_path, markdown(delivered_receipt)),) + writer = self.writer() + + self.assertTrue( + writer._delivery_binds_pull_request( + checkout, + work_path, + delivered_work, + authorized_claim, + delivered_claim, + receipt_change, + ) + ) + + stale_claim = copy.deepcopy(published_claim) + stale_delivered_claim = copy.deepcopy(stale_claim) + stale_delivered_claim["candidate"] = replacement_candidate + self.assertFalse( + writer._delivery_binds_pull_request( + checkout, + work_path, + delivered_work, + stale_claim, + stale_delivered_claim, + receipt_change, + ) + ) + + for field, wrong_value in ( + ("candidate_head", "c" * 40), + ("candidate_remote_ref", "refs/heads/scott/other-candidate"), + ): + wrong_authorized = copy.deepcopy(authorized_claim) + wrong_authorized["checkpoint"]["authorizations"][-1][field] = wrong_value + wrong_delivered = copy.deepcopy(wrong_authorized) + wrong_delivered["candidate"] = replacement_candidate + with self.subTest(field=field): + self.assertFalse( + writer._delivery_binds_pull_request( + checkout, + work_path, + delivered_work, + wrong_authorized, + wrong_delivered, + receipt_change, + ) + ) + + self.assertFalse( + writer._delivery_binds_pull_request( + checkout, + work_path, + delivered_work, + authorized_claim, + delivered_claim, + (), + ) + ) + delivered_receipt["candidate"] = published_candidate + fixtures.write_markdown(checkout / receipt_path, delivered_receipt) + self.assertFalse( + writer._delivery_binds_pull_request( + checkout, + work_path, + delivered_work, + authorized_claim, + delivered_claim, + receipt_change, + ) + ) + def test_option_looking_remote_is_rejected_before_git_runs(self) -> None: calls: list[tuple[str, ...]] = [] @@ -1631,12 +1806,21 @@ def plan(context: TransitionContext) -> TransitionPlan: return claim_value published_claim = fixtures.claim(self.repository, 1, with_candidate=True) - for authorization in published_claim["checkpoint"]["authorizations"]: + authorizations = copy.deepcopy(published_claim["checkpoint"]["authorizations"]) + push_after_pr = copy.deepcopy(authorizations[0]) + push_after_pr["sequence"] = 4 + publish_after_pr = copy.deepcopy(authorizations[1]) + publish_after_pr["sequence"] = 5 + publish_after_pr["candidate_pull_request"] = published_claim["candidate"]["pull_request"] + authorizations.extend((push_after_pr, publish_after_pr)) + for authorization in authorizations: claim_value["checkpoint"]["authorizations"].append(copy.deepcopy(authorization)) claim_value["checkpoint"]["sequence"] = authorization["sequence"] claim_value["checkpoint"]["continuation_token"] = f"token-1-{authorization['sequence']}" if authorization["phase"] == "candidate_published": claim_value["candidate"] = copy.deepcopy(published_claim["candidate"]) + if authorization["sequence"] != 5: + claim_value["candidate"]["pull_request"] = None checkpoint_claim = copy.deepcopy(claim_value) checkpoint_number = authorization["sequence"] diff --git a/contract_tests/test_mailbox.py b/contract_tests/test_mailbox.py index 852b8617..520d436b 100644 --- a/contract_tests/test_mailbox.py +++ b/contract_tests/test_mailbox.py @@ -171,6 +171,7 @@ def claim(repository: str, number: int, *, with_candidate: bool) -> dict[str, An "proposed_effect_digest": DIGEST, "candidate_head": SHA_B, "candidate_remote_ref": f"refs/heads/scott/work-{number}", + "candidate_pull_request": None, "acknowledged_candidate_head": None, "recorded_at": TIMESTAMP, }, @@ -182,6 +183,7 @@ def claim(repository: str, number: int, *, with_candidate: bool) -> dict[str, An "proposed_effect_digest": DIGEST, "candidate_head": SHA_B, "candidate_remote_ref": f"refs/heads/scott/work-{number}", + "candidate_pull_request": None, "acknowledged_candidate_head": SHA_B, "recorded_at": TIMESTAMP, }, @@ -193,6 +195,7 @@ def claim(repository: str, number: int, *, with_candidate: bool) -> dict[str, An "proposed_effect_digest": DIGEST, "candidate_head": SHA_B, "candidate_remote_ref": f"refs/heads/scott/work-{number}", + "candidate_pull_request": None, "acknowledged_candidate_head": None, "recorded_at": TIMESTAMP, }, @@ -203,6 +206,7 @@ def claim(repository: str, number: int, *, with_candidate: bool) -> dict[str, An return { "id": identifier("clm", number), "worker_run_id": worker_run_id, + "inherited_receipt_id": None, "work_revision": 1, "approved_commit": SHA_A, "policy_commit": SHA_A, @@ -922,6 +926,7 @@ def test_checkpoint_ledger_tracks_acknowledged_candidate_history(self) -> None: "proposed_effect_digest": DIGEST, "candidate_head": "d" * 40, "candidate_remote_ref": work["claim"]["candidate"]["remote_ref"], + "candidate_pull_request": None, "acknowledged_candidate_head": None, "recorded_at": TIMESTAMP, } @@ -964,7 +969,7 @@ def test_append_only_candidate_publication_history_reconstructs(self) -> None: work = self.fixture.works[work_id] next_head = "d" * 40 checkpoint = work["claim"]["checkpoint"] - checkpoint["sequence"] = 5 + checkpoint["sequence"] = 7 checkpoint["continuation_token"] = "token-1-next" checkpoint["authorizations"].extend( [ @@ -974,8 +979,9 @@ def test_append_only_candidate_publication_history_reconstructs(self) -> None: "phase": "pre_external_mutation", "action": "repository.candidate.push", "proposed_effect_digest": DIGEST, - "candidate_head": next_head, + "candidate_head": SHA_B, "candidate_remote_ref": work["claim"]["candidate"]["remote_ref"], + "candidate_pull_request": None, "acknowledged_candidate_head": None, "recorded_at": TIMESTAMP, }, @@ -985,8 +991,33 @@ def test_append_only_candidate_publication_history_reconstructs(self) -> None: "phase": "candidate_published", "action": "repository.candidate.push", "proposed_effect_digest": DIGEST, + "candidate_head": SHA_B, + "candidate_remote_ref": work["claim"]["candidate"]["remote_ref"], + "candidate_pull_request": work["claim"]["candidate"]["pull_request"], + "acknowledged_candidate_head": SHA_B, + "recorded_at": TIMESTAMP, + }, + { + "sequence": 6, + "invocation_id": work["claim"]["worker_run_id"], + "phase": "pre_external_mutation", + "action": "repository.candidate.push", + "proposed_effect_digest": DIGEST, + "candidate_head": next_head, + "candidate_remote_ref": work["claim"]["candidate"]["remote_ref"], + "candidate_pull_request": None, + "acknowledged_candidate_head": None, + "recorded_at": TIMESTAMP, + }, + { + "sequence": 7, + "invocation_id": work["claim"]["worker_run_id"], + "phase": "candidate_published", + "action": "repository.candidate.push", + "proposed_effect_digest": DIGEST, "candidate_head": next_head, "candidate_remote_ref": work["claim"]["candidate"]["remote_ref"], + "candidate_pull_request": work["claim"]["candidate"]["pull_request"], "acknowledged_candidate_head": next_head, "recorded_at": TIMESTAMP, }, @@ -1004,6 +1035,13 @@ def test_append_only_candidate_publication_history_reconstructs(self) -> None: self.assertEqual(snapshot["views"]["delivered"], [work_id]) + def test_claim_requires_causal_inherited_receipt_binding(self) -> None: + work_id = self.fixture.add_work(1, "active") + del self.fixture.works[work_id]["claim"]["inherited_receipt_id"] + self.fixture.write_work(work_id) + + self.assert_invalid("schema-one-of") + def test_transferable_candidate_can_be_adopted_by_a_fresh_claim(self) -> None: work_id = self.fixture.add_work(1, "active") work = self.fixture.works[work_id] @@ -1022,6 +1060,7 @@ def test_transferable_candidate_can_be_adopted_by_a_fresh_claim(self) -> None: self.fixture.receipts[work_id][released["id"]] = released adopted_claim = claim(repository, 2, with_candidate=False) adopted_claim["candidate"] = copy.deepcopy(prior_claim["candidate"]) + adopted_claim["inherited_receipt_id"] = released["id"] work["claim"] = adopted_claim self.fixture.write_work(work_id) diff --git a/docs/git-mailbox-contract.md b/docs/git-mailbox-contract.md index 85f5402d..e54cefcf 100644 --- a/docs/git-mailbox-contract.md +++ b/docs/git-mailbox-contract.md @@ -412,6 +412,7 @@ Its normative shape inside `work.md` is: claim: id: clm_019f9a9e-0000-7000-8000-000000000001 worker_run_id: run_019f9a9e-0000-7000-8000-000000000001 + inherited_receipt_id: null work_revision: 1 approved_commit: 0123456789abcdef0123456789abcdef01234567 policy_commit: 0123456789abcdef0123456789abcdef01234567 @@ -426,6 +427,14 @@ claim: candidate: null ``` +`inherited_receipt_id` is required and immutable. It is `null` when the claim +did not adopt a transferable predecessor candidate. Claim or takeover sets it to +the exact receipt whose transferable candidate is copied into the new claim, in +the same atomic transition. Static reconstruction resolves only that named +receipt; timestamps, filename order, and other same-lineage receipts never infer +predecessor provenance. A missing, non-transferable, self-owned, or mismatched +receipt fails closed. + Delegation preparation changes `invocation_digest` exactly once from `null` to the SHA-256 digest of the complete canonical delegated invocation. The digest is immutable thereafter and every checkpoint and terminal result must reproduce @@ -441,14 +450,18 @@ action: repository.candidate.create proposed_effect_digest: sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef candidate_head: null candidate_remote_ref: null +candidate_pull_request: null acknowledged_candidate_head: null recorded_at: 2026-07-25T12:06:00Z ``` `phase` is `pre_external_mutation` or `candidate_published`; `action` uses the Agent Scripts v2 vocabulary. SHA fields are exact candidate revisions or `null` -when the action has no candidate. All other fields are required. Entries are in -strictly increasing sequence order and are append-only. When transferable +when the action has no candidate. `candidate_pull_request` is required on every +entry. It is `null` for every `pre_external_mutation` entry and is the exact +GitHub pull-request URL or `null` on `candidate_published`. All fields are +required. Entries are in strictly increasing sequence order and are append-only. +When transferable implementation state first exists, the worker updates the claim only after publishing and verifying it: @@ -465,6 +478,14 @@ candidate: published_at: 2026-07-25T12:20:00Z ``` +The transferable candidate identity is the exact tuple (`repository`, `remote`, +`remote_url`, `remote_ref`, `base_revision`, `head_revision`). `pull_request` is +publication metadata, not part of that identity; `workspace_id` and +`published_at` are also not identity fields. Candidate lineage is the same tuple +without `head_revision`. A head advance therefore changes candidate identity but +remains on the same lineage when repository, remote, remote URL, remote ref, and +base revision are unchanged. + `workspace_id` may name a durable host workspace or task but must not contain a machine-local path. The candidate is valid only when `head_revision` is reachable from `remote_ref` on the verified declared remote. A local-only SHA, @@ -499,6 +520,25 @@ remote reachability is reverified, and the blocked receipt binds the candidate i same atomic mailbox transition. That receipt becomes the verified transferable handoff for a later claim. +A `candidate_published` entry is also the append-only provenance for current PR +metadata. The first introduction of a PR URL, and every replacement with a +different URL, requires a fresh `pull_request.create` or `pull_request.update` +`pre_external_mutation` entry after the preceding `candidate_published` entry +and before the new publication. That authority must name the publication's exact +candidate head and remote ref. The new `candidate_published` entry binds the +authorized URL in `candidate_pull_request`. A previously published, unchanged PR +URL may be retained across a head advance without another PR mutation only when +the candidate remains on the same lineage. A push by itself never authorizes PR +introduction or replacement. + +A delivered terminal transition may normalize an introduced or replaced PR URL +directly into the claim candidate and its newly written matching delivery receipt, +without an intermediate `candidate_published` checkpoint, only under that same fresh +exact-head-and-ref rule. The exact `pull_request.create` or `pull_request.update` +authorization must follow the preceding publication and is consumed by that terminal +receipt. Otherwise terminal candidate PR metadata must equal the latest published or +inherited value. + Before accepting any terminal result, Atelier requires its sequence and continuation token to equal the current claim-ledger tail. It also requires the terminal `authority_used` set to equal the allowed `pre_external_mutation` @@ -515,7 +555,17 @@ new claim when the replaced claim has one. A takeover never silently discards a candidate: when no transferable handoff exists it preserves and references the current attempt receipt explaining that fact. A takeover creates a new claim identifier and worker-run identifier; it never edits the old identifiers in -history. +history. Blocked and released transferable receipts preserve the complete exact +candidate, including its PR metadata. A reclaimed claim adopts that complete +candidate and records the exact causal receipt in `inherited_receipt_id`; its +checkpoint replay begins with only that receipt's inherited PR value. Merely +preserving that URL through a blocked, released, reclaimed, or later same-lineage +candidate state consumes no new PR mutation authority. After reclaim, the bound +transferable receipt remains PR provenance for a changed head only when the new +candidate is on that same lineage and an exact-head-and-ref push is followed by +its `candidate_published` acknowledgement. Cross-lineage or unacknowledged state +cannot inherit PR provenance. Stale, introduced, replaced, or otherwise changed +PR metadata must satisfy the fresh-authority fences above or fail closed. Two workers may race to claim the same approved work locally. Only the worker whose commit first advances the canonical remote branch owns the claim. Every @@ -752,11 +802,14 @@ preconditions: - **Claim.** A worker requires approved work whose readiness and capability gates pass. The commit publishes active work and its claim. If `attempt_receipt_id` identifies a verified transferable handoff, the new claim - adopts that exact candidate. A losing claim stops. + atomically adopts that exact candidate and records the same receipt as + `inherited_receipt_id`; otherwise the binding is `null`. A losing claim stops. - **Register or update candidate.** The claiming worker requires its exact active claim and unconsumed checkpoint and publishes the exact candidate while - advancing the checkpoint. Retry is valid only for the same candidate - transition. + advancing the checkpoint. The publication records required PR metadata + provenance; PR introduction or replacement requires the fresh exact authority + described above, while an unchanged URL may follow a same-lineage head advance. + Retry is valid only for the same candidate transition. - **Authorize external mutation.** The claiming worker requires its exact active claim and unconsumed checkpoint. The commit advances the checkpoint only after current revision, policy, ticket, candidate, and authority pass. The returned diff --git a/experiments/mailbox_protocol_v0.py b/experiments/mailbox_protocol_v0.py index 3c208193..8f535800 100644 --- a/experiments/mailbox_protocol_v0.py +++ b/experiments/mailbox_protocol_v0.py @@ -109,6 +109,7 @@ def claim(claim_id: str, run_id: str, candidate: dict[str, Any] | None = None) - return { "id": claim_id, "worker_run_id": run_id, + "inherited_receipt_id": None, "work_revision": 1, "approved_commit": SHA_ZERO, "policy_commit": SHA_ZERO, @@ -313,6 +314,7 @@ def authorize_checkpoint( "proposed_effect_digest": ticket_digest({"action": action, "claim_id": claim_id}), "candidate_head": None, "candidate_remote_ref": None, + "candidate_pull_request": None, "acknowledged_candidate_head": None, "recorded_at": "2026-07-25T12:25:00Z", } diff --git a/skills/atelier/references/mailbox-v1.schema.json b/skills/atelier/references/mailbox-v1.schema.json index 34d47027..d647eeca 100644 --- a/skills/atelier/references/mailbox-v1.schema.json +++ b/skills/atelier/references/mailbox-v1.schema.json @@ -113,6 +113,7 @@ "proposed_effect_digest", "candidate_head", "candidate_remote_ref", + "candidate_pull_request", "acknowledged_candidate_head", "recorded_at" ], @@ -149,6 +150,17 @@ } ] }, + "candidate_pull_request": { + "oneOf": [ + { + "type": "null" + }, + { + "type": "string", + "pattern": "^https://github\\.com/[^/]+/[^/]+/pull/[1-9][0-9]*$" + } + ] + }, "acknowledged_candidate_head": { "$ref": "#/$defs/nullable_sha" }, @@ -239,6 +251,7 @@ "required": [ "id", "worker_run_id", + "inherited_receipt_id", "work_revision", "approved_commit", "policy_commit", @@ -253,6 +266,9 @@ "id": { "$ref": "#/$defs/claim_id" }, + "inherited_receipt_id": { + "$ref": "#/$defs/nullable_receipt_id" + }, "worker_run_id": { "$ref": "#/$defs/run_id" }, diff --git a/skills/atelier/scripts/claiming.py b/skills/atelier/scripts/claiming.py index cae2720f..1beb3a9e 100644 --- a/skills/atelier/scripts/claiming.py +++ b/skills/atelier/scripts/claiming.py @@ -247,13 +247,14 @@ def revalidate(context: TransitionContext) -> None: raise MailboxTransitionRejected( f"{work_id}: project already has active work: {', '.join(active)}" ) - candidate = self._released_candidate(context, work) + candidate, inherited_receipt_id = self._released_candidate(context, work) planned.clear() planned.update( state=state, claim={ "id": claim_id, "worker_run_id": worker_run_id, + "inherited_receipt_id": inherited_receipt_id, "work_revision": work["revision"], "approved_commit": approved_commit, "policy_commit": state.current_policy.commit, @@ -546,6 +547,7 @@ def revalidate(context: TransitionContext) -> None: new_claim = { "id": claim_id, "worker_run_id": worker_run_id, + "inherited_receipt_id": None, "work_revision": state.work["revision"], "approved_commit": approved_commit, "policy_commit": state.current_policy.commit, @@ -577,6 +579,17 @@ def revalidate(context: TransitionContext) -> None: ended_at=taken_over_at, evidence=AttemptEvidence(), ) + if prior_claim["candidate"] is not None: + inherited_receipt_id = ( + takeover_receipt_id + if current_status == "active" + else state.work["attempt_receipt_id"] + ) + if inherited_receipt_id is None: + raise MailboxTransitionRejected( + f"{work_id}: takeover candidate lacks a predecessor receipt" + ) + new_claim["inherited_receipt_id"] = inherited_receipt_id current_blocker = state.work["blocking_message_id"] takeover_message = { "schema": "atelier.message/v1", @@ -758,10 +771,10 @@ def _released_candidate( self, context: TransitionContext, work: Mapping[str, Any], - ) -> dict[str, Any] | None: + ) -> tuple[dict[str, Any] | None, str | None]: receipt_id = work["attempt_receipt_id"] if receipt_id is None: - return None + return None, None receipt, _ = _read_document(context.checkout / _receipt_path(work["id"], receipt_id)) if receipt["outcome"] != "released": raise MailboxTransitionRejected( @@ -769,7 +782,7 @@ def _released_candidate( ) candidate = receipt["candidate"] if receipt["handoff"] == "transferable" else None self._verify_candidate(candidate) - return copy.deepcopy(candidate) + return copy.deepcopy(candidate), receipt_id if candidate is not None else None def _apply_checkpoint( self, @@ -806,6 +819,40 @@ def _apply_checkpoint( raise MailboxTransitionRejected( "candidate publication does not match the preceding push authorization" ) + current_candidate = claim["candidate"] + current_pull_request = ( + current_candidate["pull_request"] if current_candidate is not None else None + ) + same_candidate_lineage = current_candidate is not None and ( + candidate["repository"], + candidate["remote"], + candidate["remote_url"], + candidate["remote_ref"], + candidate["base_revision"], + ) == ( + current_candidate["repository"], + current_candidate["remote"], + current_candidate["remote_url"], + current_candidate["remote_ref"], + current_candidate["base_revision"], + ) + retained_pull_request = ( + candidate["pull_request"] is not None + and candidate["pull_request"] == current_pull_request + and same_candidate_lineage + ) + pull_request_requires_authority = ( + candidate["pull_request"] != current_pull_request + or (candidate["pull_request"] is not None and not retained_pull_request) + ) + if pull_request_requires_authority and not _pull_request_mutation_authorized( + claim, + candidate_head=request.candidate_head, + candidate_remote_ref=request.candidate_remote_ref, + ): + raise MailboxTransitionRejected( + "candidate pull request metadata lacks exact pre-mutation authority" + ) self._verify_candidate(candidate) claim["candidate"] = candidate else: @@ -848,6 +895,9 @@ def _apply_checkpoint( "proposed_effect_digest": request.proposed_effect_digest, "candidate_head": request.candidate_head, "candidate_remote_ref": request.candidate_remote_ref, + "candidate_pull_request": ( + candidate["pull_request"] if request.phase == "candidate_published" else None + ), "acknowledged_candidate_head": request.acknowledged_candidate_head, "recorded_at": _timestamp(request.recorded_at), } @@ -866,6 +916,34 @@ def _verify_candidate(self, candidate: Mapping[str, Any] | None) -> None: ) + +def _pull_request_mutation_authorized( + claim: Mapping[str, Any], + *, + candidate_head: str | None, + candidate_remote_ref: str | None, +) -> bool: + if candidate_head is None or candidate_remote_ref is None: + return False + ledger = claim["checkpoint"]["authorizations"] + prior_publication_sequence = max( + ( + entry["sequence"] + for entry in ledger + if entry["phase"] == "candidate_published" + ), + default=0, + ) + return any( + entry["sequence"] > prior_publication_sequence + and entry["phase"] == "pre_external_mutation" + and entry["action"] in {"pull_request.create", "pull_request.update"} + and entry["candidate_head"] == candidate_head + and entry["candidate_remote_ref"] == candidate_remote_ref + for entry in ledger + ) + + def _effective_policy( approved: Mapping[str, Any], current: Mapping[str, Any], diff --git a/skills/atelier/scripts/delegation.py b/skills/atelier/scripts/delegation.py index e9d698b6..87bd87d3 100644 --- a/skills/atelier/scripts/delegation.py +++ b/skills/atelier/scripts/delegation.py @@ -32,6 +32,7 @@ ClaimFence, HostTarget, _attempt_receipt, + _pull_request_mutation_authorized, _message_path, _read_work, _receipt_path, @@ -592,6 +593,31 @@ def _require_checkpoint_candidate( ) +def _publication_pull_request(value: Mapping[str, Any]) -> str | None: + publication = value.get("publication") + if publication is None: + return None + pull_requests = publication["pull_requests"] + if publication["kind"] != "ordinary" or len(pull_requests) > 1: + raise MailboxTransitionRejected("Atelier v0 accepts at most one ordinary pull request") + if not pull_requests: + return None + pull_request = pull_requests[0] + if ( + pull_request["head_ref"], + pull_request["head_sha"], + pull_request["base_sha"], + ) != ( + value["remote_ref"], + value["head_sha"], + value["base_sha"], + ): + raise MailboxTransitionRejected( + "terminal pull request does not identify the exact candidate" + ) + return pull_request["url"] + + def _mailbox_candidate( value: Mapping[str, Any] | None, recorded_at: datetime ) -> dict[str, Any] | None: @@ -604,7 +630,7 @@ def _mailbox_candidate( "remote_ref": value["remote_ref"], "base_revision": value["base_sha"], "head_revision": value["head_sha"], - "pull_request": None, + "pull_request": _publication_pull_request(value), "workspace_id": None, "published_at": _timestamp(recorded_at), } @@ -686,6 +712,14 @@ def _require_ready_pr( raise MailboxTransitionRejected( "terminal candidate does not match the acknowledged claim candidate" ) + if pull_request["url"] != current["pull_request"] and not _pull_request_mutation_authorized( + claim, + candidate_head=candidate["head_sha"], + candidate_remote_ref=candidate["remote_ref"], + ): + raise MailboxTransitionRejected( + "delivered pull request metadata lacks fresh exact pre-mutation authority" + ) live = observation["pull_request"] if live is None or ( live["url"], @@ -762,6 +796,16 @@ def _blocked_candidate( if current is not None: raise MailboxTransitionRejected("blocked result omitted an acknowledged candidate") return None + pull_request = _publication_pull_request(candidate) + current_pull_request = current["pull_request"] if current is not None else None + if pull_request != current_pull_request and not _pull_request_mutation_authorized( + claim, + candidate_head=candidate["head_sha"], + candidate_remote_ref=candidate["remote_ref"], + ): + raise MailboxTransitionRejected( + "blocked pull request metadata lacks fresh exact pre-mutation authority" + ) candidate_identity = ( candidate["repository"], candidate["remote_url"], @@ -776,7 +820,10 @@ def _blocked_candidate( current["base_revision"], current["head_revision"], ): - return copy.deepcopy(current) + value = copy.deepcopy(current) + if pull_request is not None: + value["pull_request"] = pull_request + return value ledger = claim["checkpoint"]["authorizations"] if ( result["implementation_state"] != "published" diff --git a/skills/atelier/scripts/git_mailbox.py b/skills/atelier/scripts/git_mailbox.py index c42923aa..fa049aec 100644 --- a/skills/atelier/scripts/git_mailbox.py +++ b/skills/atelier/scripts/git_mailbox.py @@ -606,6 +606,7 @@ def _verify_claim_history( continue binding_fields = ( "worker_run_id", + "inherited_receipt_id", "work_revision", "approved_commit", "policy_commit", @@ -748,7 +749,7 @@ def _delivery_binds_pull_request( current_claim: Mapping[str, Any], changes: tuple[FileChange, ...], ) -> bool: - """Allow delivery to bind the exact PR created under the last authorization.""" + """Bind one fresh exact PR mutation into a newly written delivery receipt.""" prior_candidate = prior_claim["candidate"] current_candidate = current_claim["candidate"] @@ -757,8 +758,7 @@ def _delivery_binds_pull_request( expected = dict(prior_candidate) expected["pull_request"] = current_candidate["pull_request"] if ( - prior_candidate["pull_request"] is not None - or not isinstance(current_candidate["pull_request"], str) + not isinstance(current_candidate["pull_request"], str) or not current_candidate["pull_request"].startswith("https://github.com/") or current_candidate != expected or current_claim["checkpoint"] != prior_claim["checkpoint"] @@ -767,14 +767,26 @@ def _delivery_binds_pull_request( ): return False ledger = current_claim["checkpoint"]["authorizations"] - if ( - not ledger - or ledger[-1]["phase"] != "pre_external_mutation" - or ledger[-1]["action"] != "pull_request.create" - or ledger[-1]["candidate_head"] != current_candidate["head_revision"] + prior_publication_sequence = max( + ( + entry["sequence"] + for entry in ledger + if entry["phase"] == "candidate_published" + ), + default=0, + ) + if not any( + entry["sequence"] > prior_publication_sequence + and entry["phase"] == "pre_external_mutation" + and entry["action"] in {"pull_request.create", "pull_request.update"} + and entry["candidate_head"] == current_candidate["head_revision"] + and entry["candidate_remote_ref"] == current_candidate["remote_ref"] + for entry in ledger ): return False receipt_id = document["delivery_receipt_id"] + if not isinstance(receipt_id, str): + return False receipt_path = f"work/{PurePosixPath(path).parts[1]}/receipts/{receipt_id}.md" if not any( change.path == receipt_path and change.content is not None for change in changes diff --git a/skills/atelier/scripts/mailbox.py b/skills/atelier/scripts/mailbox.py index f80236d5..89d0d403 100644 --- a/skills/atelier/scripts/mailbox.py +++ b/skills/atelier/scripts/mailbox.py @@ -888,6 +888,7 @@ def _validate_claim( work_id: str, work: dict[str, Any], attempt_receipt: dict[str, Any] | None, + work_receipts: dict[str, dict[str, Any]], ) -> list[Diagnostic]: claim = work["claim"] if claim is None: @@ -915,40 +916,129 @@ def _validate_claim( Diagnostic(path, "checkpoint-invocation", "authorization names another worker run") ) authority_ceiling = set(approval["authority_ceiling"]) if approval is not None else set() + candidate = claim["candidate"] + + def same_identity(left: dict[str, Any] | None, right: dict[str, Any] | None) -> bool: + return left is not None and right is not None and ( + left["repository"], + left["remote"], + left["remote_url"], + left["remote_ref"], + left["base_revision"], + left["head_revision"], + ) == ( + right["repository"], + right["remote"], + right["remote_url"], + right["remote_ref"], + right["base_revision"], + right["head_revision"], + ) + + def differs_only_by_pull_request( + left: dict[str, Any] | None, + right: dict[str, Any] | None, + ) -> bool: + if left is None or right is None: + return False + expected = dict(left) + expected["pull_request"] = right["pull_request"] + return expected == right + + def same_lineage(left: dict[str, Any] | None, right: dict[str, Any] | None) -> bool: + return left is not None and right is not None and ( + left["repository"], + left["remote"], + left["remote_url"], + left["remote_ref"], + left["base_revision"], + ) == ( + right["repository"], + right["remote"], + right["remote_url"], + right["remote_ref"], + right["base_revision"], + ) + + inherited_receipt_id = claim["inherited_receipt_id"] + inherited_receipt = ( + work_receipts.get(inherited_receipt_id) if inherited_receipt_id is not None else None + ) + inherited_candidate_source = None + if inherited_receipt_id is not None: + if inherited_receipt is None: + diagnostics.append( + Diagnostic( + path, + "claim-inherited-receipt", + "claim names a missing inherited attempt receipt", + ) + ) + elif ( + inherited_receipt["claim_id"] == claim["id"] + or inherited_receipt["handoff"] != "transferable" + or inherited_receipt["candidate"] is None + or not same_lineage(inherited_receipt["candidate"], candidate) + ): + diagnostics.append( + Diagnostic( + path, + "claim-inherited-receipt", + "claim does not preserve its exact transferable predecessor receipt", + ) + ) + else: + inherited_candidate_source = inherited_receipt["candidate"] transferable_handoff = ( attempt_receipt is not None and attempt_receipt["handoff"] == "transferable" and attempt_receipt["claim_id"] != claim["id"] ) + if transferable_handoff and inherited_receipt_id != work["attempt_receipt_id"]: + diagnostics.append( + Diagnostic( + path, + "claim-inherited-receipt", + "claim does not bind the current transferable predecessor receipt", + ) + ) recovered_current_candidate = ( attempt_receipt is not None and attempt_receipt["outcome"] == "blocked" and attempt_receipt["handoff"] == "transferable" and attempt_receipt["claim_id"] == claim["id"] - and attempt_receipt["candidate"] == claim["candidate"] + and attempt_receipt["candidate"] == candidate and bool(ledger) and ledger[-1]["phase"] == "pre_external_mutation" and ledger[-1]["action"] == "repository.candidate.push" - and claim["candidate"] is not None - and ledger[-1]["candidate_head"] == claim["candidate"]["head_revision"] - and ledger[-1]["candidate_remote_ref"] == claim["candidate"]["remote_ref"] + and candidate is not None + and ledger[-1]["candidate_head"] == candidate["head_revision"] + and ledger[-1]["candidate_remote_ref"] == candidate["remote_ref"] ) - latest_acknowledged_head = ( - attempt_receipt["candidate"]["head_revision"] - if transferable_handoff and attempt_receipt["candidate"] is not None + acknowledged_source = ( + inherited_candidate_source + if same_identity(inherited_candidate_source, candidate) else None ) + latest_acknowledged_head = ( + acknowledged_source["head_revision"] if acknowledged_source is not None else None + ) latest_acknowledged_ref = ( - attempt_receipt["candidate"]["remote_ref"] - if transferable_handoff and attempt_receipt["candidate"] is not None + acknowledged_source["remote_ref"] if acknowledged_source is not None else None + ) + latest_published_pull_request = ( + inherited_candidate_source["pull_request"] + if inherited_candidate_source is not None else None ) + prior_publication_sequence = 0 previous_entry: dict[str, Any] | None = None for entry in ledger: action = entry["action"] phase = entry["phase"] candidate_head = entry["candidate_head"] candidate_remote_ref = entry["candidate_remote_ref"] + candidate_pull_request = entry["candidate_pull_request"] acknowledged_head = entry["acknowledged_candidate_head"] if action not in authority_ceiling: diagnostics.append( @@ -991,7 +1081,35 @@ def _validate_claim( elif paired_push: latest_acknowledged_head = candidate_head latest_acknowledged_ref = candidate_remote_ref + if candidate_pull_request != latest_published_pull_request: + pull_request_mutation_authorized = any( + prior["sequence"] > prior_publication_sequence + and prior["sequence"] < entry["sequence"] + and prior["phase"] == "pre_external_mutation" + and prior["action"] in {"pull_request.create", "pull_request.update"} + and prior["candidate_head"] == candidate_head + and prior["candidate_remote_ref"] == candidate_remote_ref + for prior in ledger + ) + if not pull_request_mutation_authorized: + diagnostics.append( + Diagnostic( + path, + "checkpoint-pr-authority", + "PR candidate metadata lacks fresh exact pre-mutation authority", + ) + ) + latest_published_pull_request = candidate_pull_request + prior_publication_sequence = entry["sequence"] else: + if candidate_pull_request is not None: + diagnostics.append( + Diagnostic( + path, + "checkpoint-phase", + "pre_external_mutation must not publish pull request metadata", + ) + ) if acknowledged_head is not None: diagnostics.append( Diagnostic( @@ -1027,7 +1145,6 @@ def _validate_claim( ) ) previous_entry = entry - candidate = claim["candidate"] publication_entries = [entry for entry in ledger if entry["phase"] == "candidate_published"] internally_invalid = any( entry["candidate_head"] is None @@ -1038,21 +1155,53 @@ def _validate_claim( inherited_candidate = ( candidate is not None and not publication_entries - and (transferable_handoff or recovered_current_candidate) + and ( + recovered_current_candidate + or inherited_candidate_source == candidate + ) + ) + terminal_pull_request_normalization = ( + candidate is not None + and attempt_receipt is not None + and attempt_receipt["outcome"] in {"blocked", "delivered"} and attempt_receipt["candidate"] == candidate + and any( + entry["sequence"] > prior_publication_sequence + and entry["phase"] == "pre_external_mutation" + and entry["action"] in {"pull_request.create", "pull_request.update"} + and entry["candidate_head"] == candidate["head_revision"] + and entry["candidate_remote_ref"] == candidate["remote_ref"] + for entry in ledger + ) ) current_unacknowledged = ( candidate is not None and not inherited_candidate and not recovered_current_candidate + and not ( + terminal_pull_request_normalization + and differs_only_by_pull_request(inherited_candidate_source, candidate) + ) and ( not publication_entries or publication_entries[-1]["candidate_head"] != candidate["head_revision"] or publication_entries[-1]["candidate_remote_ref"] != candidate["remote_ref"] ) ) + acknowledged_same_lineage_advance = ( + inherited_candidate_source is not None + and candidate is not None + and same_lineage(inherited_candidate_source, candidate) + and bool(publication_entries) + and publication_entries[-1]["candidate_head"] == candidate["head_revision"] + and publication_entries[-1]["candidate_remote_ref"] == candidate["remote_ref"] + ) discarded_handoff = transferable_handoff and ( - candidate is None or attempt_receipt["candidate"] != candidate + candidate is None + or ( + not same_identity(attempt_receipt["candidate"], candidate) + and not acknowledged_same_lineage_advance + ) ) if ( internally_invalid @@ -1068,20 +1217,15 @@ def _validate_claim( ) ) if ( - work["status"] == "delivered" - and candidate is not None - and candidate["pull_request"] is not None - and not any( - entry["phase"] == "pre_external_mutation" - and entry["action"] in {"pull_request.create", "pull_request.update"} - for entry in ledger - ) + candidate is not None + and candidate["pull_request"] != latest_published_pull_request + and not terminal_pull_request_normalization ): diagnostics.append( Diagnostic( path, "checkpoint-pr-authority", - "delivered PR candidate lacks recorded pre-mutation authority", + "PR candidate metadata lacks fresh exact publication provenance", ) ) return diagnostics @@ -1183,7 +1327,7 @@ def _validate_lifecycle( diagnostics.append( Diagnostic(path, "approval-revision", "approval does not name the current revision") ) - diagnostics.extend(_validate_claim(work_id, work, attempt_receipt)) + diagnostics.extend(_validate_claim(work_id, work, attempt_receipt, work_receipts)) if claim is not None and any( receipt["outcome"] == "released" and (