diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index d3d233d8d9..67e20e10c1 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -14,6 +14,42 @@ concrete gap and consumer. These author facts are independently reviewed. - Observable before → after, with the validation row that proves it: - Issue/task and intended base: +## Author Declaration + + + +- Written by: + +### Implemented against + + + +- Specification and revision: +- Criteria: + +| Criterion (spec clause) | Disposition | Symbol / path | Test or command | +| --- | --- | --- | --- | +| | | | | + +- Self-check before submission: + ## Scope And Continuation \n\n" + result["review_body"] + assert "reviewer:body_line_missing_or_ambiguous" in check_review_result(packet, result)["errors"] + + +def test_published_reviewer_line_must_match_the_declaration(): + packet, result = _review() + result["reviewer"]["declared_model"] = "Another Model 2" + checked = check_review_result(packet, result) + assert not checked["ok"] + assert "reviewer:body_line_disagrees_with_declaration" in checked["errors"] + + +@pytest.mark.parametrize("field, value, reason", [ + ("actor_kind", "agent", "reviewer:invalid_actor_kind"), + ("declaration_source", "guessed", "reviewer:invalid_declaration_source"), + ("declared_model", "", "reviewer:missing_field:declared_model"), + ("declared_provider", " ", "reviewer:missing_field:declared_provider"), + ("declared_model", "https://gateway.example/v1", "reviewer:not_a_product_family_name:declared_model"), + ("declared_provider", "ops@example", "reviewer:not_a_product_family_name:declared_provider"), +]) +def test_reviewer_declaration_rejects_unknown_or_infrastructure_values(field, value, reason): + packet, result = _review() + result["reviewer"][field] = value + assert reason in check_review_result(packet, result)["errors"] + + +def test_missing_reviewer_declaration_blocks_publication_of_any_verdict(): + packet, result = _review() + del result["reviewer"] + result["verdict"] = "REQUEST_CHANGES" + result["review_body"] = result["review_body"].replace( + "English verdict: APPROVE", "English verdict: REQUEST_CHANGES") + checked = check_review_result(packet, result) + assert "reviewer:missing_declaration" in checked["errors"] + assert not checked["ok"] + + +def test_human_operator_needs_no_model_or_provider(): + packet, result = _review() + result["reviewer"] = {"actor_kind": "human_operator", "declaration_source": "self_reported"} + result["review_body"] = result["review_body"].replace(REVIEWER_LINE, "Reviewer: human_operator") + assert check_review_result(packet, result)["ok"] + + +def test_mapped_spec_basis_binds_criteria_to_the_head_and_the_published_body(): + packet, result = _review() + result["evidence"]["problem_context"]["spec_basis"] = _mapped_spec_basis() + unpublished = check_review_result(packet, result) + assert not unpublished["ok"] + assert "review_body:spec_reference_not_published:EX-1" in unpublished["errors"] + assert ("review_body:spec_reference_not_published:docs/architecture/rfcs/example-v0.md#acceptance" + in unpublished["errors"]) + _publish_spec_references(result) + checked = check_review_result(packet, result) + assert checked["ok"] and checked["approval_consistent"] + + +def test_unmet_specification_criterion_cannot_be_approved(): + packet, result = _review() + basis = _mapped_spec_basis() + basis["criteria"][1] = { + "criterion_id": "EX-2", + "requirement": "Migration of old rows.", + "disposition": "not_met", + "observed_gap": "Old rows keep the previous shape.", + "minimum_repair": "Migrate old rows before readback.", + } + result["evidence"]["problem_context"]["spec_basis"] = basis + _publish_spec_references(result) + checked = check_review_result(packet, result) + assert "problem_context:spec_basis:unmet_criterion:EX-2" in checked["approval_blockers"] + assert "approval_contradicts_evidence" in checked["errors"] + result["verdict"] = "REQUEST_CHANGES" + result["review_body"] = result["review_body"].replace( + "English verdict: APPROVE", "English verdict: REQUEST_CHANGES") + assert check_review_result(packet, result)["ok"] + + +@pytest.mark.parametrize("mutation, blocker", [ + ("unknown_disposition", "problem_context:spec_basis:invalid_disposition:EX-1"), + ("implemented_without_symbol", "problem_context:spec_basis:EX-1:missing_field:symbol_or_path"), + ("deferred_without_successor", "problem_context:spec_basis:EX-2:missing_field:successor_or_gap"), + ("duplicate_criterion", "problem_context:spec_basis:duplicate_criterion:EX-1"), + ("no_revision", "problem_context:spec_basis:missing_field:spec_revision"), + ("mapped_without_source", "problem_context:spec_basis:mapped_without_spec_source"), + ("unread_spec", "problem_context:spec_basis:blocking_decision"), + ("no_spec_with_source", "problem_context:spec_basis:no_spec_cannot_cite_a_source"), + ("missing", "problem_context:spec_basis:value_not_object"), +]) +def test_spec_basis_cannot_approve_with_incomplete_or_contradictory_mapping(mutation, blocker): + packet, result = _review() + basis = _mapped_spec_basis() + if mutation == "unknown_disposition": + basis["criteria"][0]["disposition"] = "mostly_done" + elif mutation == "implemented_without_symbol": + del basis["criteria"][0]["symbol_or_path"] + elif mutation == "deferred_without_successor": + del basis["criteria"][1]["successor_or_gap"] + elif mutation == "duplicate_criterion": + basis["criteria"][1]["criterion_id"] = "EX-1" + elif mutation == "no_revision": + del basis["spec_revision"] + elif mutation == "mapped_without_source": + basis["spec_source"] = "none" + elif mutation == "unread_spec": + basis = {"decision": "not_yet_proven", "spec_source": "accepted_rfc", + "reason": "The cited RFC revision could not be opened."} + elif mutation == "no_spec_with_source": + basis = {"decision": "no_spec", "spec_source": "accepted_rfc", "reason": "x"} + if mutation == "missing": + del result["evidence"]["problem_context"]["spec_basis"] + else: + result["evidence"]["problem_context"]["spec_basis"] = basis + _publish_spec_references(result) + checked = check_review_result(packet, result) + assert blocker in checked["approval_blockers"] + assert not checked["ok"] + + @pytest.mark.parametrize("revision", [None, 0, True, "1", 5, 999]) def test_old_or_invalid_policy_cannot_certify_current_approval(revision): packet, result = _review() @@ -863,3 +1041,125 @@ def test_projected_compatibility_rules_cannot_mutate_the_checker(): packet["agent_response_contract"] = {"review_execution_contract": contract} result["evidence"]["code_volume"]["compatibility_assessment"] = _compatibility(decision="simplify_now") assert not check_review_result(packet, result)["approval_consistent"] + + +@pytest.mark.parametrize("identity", [True, False, ["EX-1"], {"id": "EX-1"}, 1, ""]) +def test_a_criterion_identity_must_be_published_text(identity): + """A criterion that cannot be named in the body is not a criterion. + + Bools hash and are non-empty, and non-strings silently fall out of the + published-body check, so the type is validated where membership, duplicate + detection and publication all assume it — not left to a later set or dict + operation that would either pass or fail with a command-level error. + """ + packet, result = _review() + basis = _mapped_spec_basis() + basis["criteria"][0]["criterion_id"] = identity + result["evidence"]["problem_context"]["spec_basis"] = basis + _publish_spec_references(result) + checked = check_review_result(packet, result) + assert "problem_context:spec_basis:invalid_criterion_id" in checked["approval_blockers"] + assert not checked["ok"] + + +@pytest.mark.parametrize("field", ["spec_ref", "spec_revision"]) +@pytest.mark.parametrize("value", [True, {"path": "x"}, ["b" * 40], 1, " "]) +def test_a_mapped_specification_reference_must_be_published_text(field, value): + """A reference the body cannot carry cannot pin the reviewed specification. + + Non-string values pass the non-empty field check and then fall out of the + published-body comparison, so an approval would cite nothing a reader on + another host could open. + """ + packet, result = _review() + basis = _mapped_spec_basis() + basis[field] = value + result["evidence"]["problem_context"]["spec_basis"] = basis + _publish_spec_references(result) + checked = check_review_result(packet, result) + assert f"problem_context:spec_basis:invalid_{field}" in checked["approval_blockers"] + assert not checked["ok"] and not checked["approval_consistent"] + + +@pytest.mark.parametrize(("criterion_id", "spec_revision", "line", "unpublished"), [ + # A criterion named only inside a longer criterion is not published. + ("EX-1", "b" * 40, "EX-10 implemented; EX-2 deferred.", "EX-1"), + # A short identity hidden inside the pinned commit id is not published. + ("bbb", "b" * 40, "EX-2 deferred.", "bbb"), +]) +def test_a_specification_reference_must_appear_as_a_whole_token( + criterion_id, spec_revision, line, unpublished, +): + """Another operator reads the body, so a substring of another word names nothing.""" + packet, result = _review() + basis = _mapped_spec_basis() + basis["criteria"][0]["criterion_id"] = criterion_id + basis["spec_revision"] = spec_revision + result["evidence"]["problem_context"]["spec_basis"] = basis + result["review_body"] += ( + "\n\nSpec basis: docs/architecture/rfcs/example-v0.md#acceptance @ " + + spec_revision + " — " + line + "\n" + ) + checked = check_review_result(packet, result) + assert f"review_body:spec_reference_not_published:{unpublished}" in checked["errors"] + assert not checked["ok"] and not checked["approval_consistent"] + + result["review_body"] += f"{criterion_id} implemented.\n" + assert check_review_result(packet, result)["ok"] + + +def test_reviewer_line_must_name_each_declared_value_as_a_whole_token(): + packet, result = _review() + result["reviewer"]["declared_provider"] = "AI" + result["review_body"] = result["review_body"].replace( + REVIEWER_LINE, "Reviewer: model_agent · Example Model 1 · OpenAI") + checked = check_review_result(packet, result) + assert "reviewer:body_line_disagrees_with_declaration" in checked["errors"] + + result["review_body"] = result["review_body"].replace( + "· OpenAI", "· AI (self-reported family)") + assert check_review_result(packet, result)["ok"] + + +@pytest.mark.parametrize("revision", ["main", "v1.2.0", "b" * 12, "B" * 39]) +def test_a_repository_specification_is_pinned_by_a_full_commit_id(revision): + """A branch, tag or abbreviated id cannot name the text the review judged.""" + packet, result = _review() + basis = _mapped_spec_basis() + basis["spec_revision"] = revision + result["evidence"]["problem_context"]["spec_basis"] = basis + result["review_body"] += ( + "\n\nSpec basis: docs/architecture/rfcs/example-v0.md#acceptance @ " + + revision + " — EX-1 implemented; EX-2 deferred.\n" + ) + checked = check_review_result(packet, result) + assert "problem_context:spec_basis:spec_revision_not_a_commit_id" in checked["approval_blockers"] + assert not checked["ok"] + + # A linked task or review thread has no commit; its revision stays free text. + basis["spec_source"] = "linked_issue_or_task" + assert check_review_result(packet, result)["ok"] + + +def test_a_published_mapping_must_carry_its_immutable_revision(): + """Another host can only open the same text if the body pins the revision. + + The path moves with the branch, so publishing the path alone lets a reader + find a different document than the one the review judged against. + """ + packet, result = _review() + result["evidence"]["problem_context"]["spec_basis"] = _mapped_spec_basis() + revision = "b" * 40 + result["review_body"] += ( + "\n\nSpec basis: docs/architecture/rfcs/example-v0.md#acceptance" + " — EX-1 implemented; EX-2 deferred.\n" + ) + assert revision not in result["review_body"] + checked = check_review_result(packet, result) + # A body-level gap blocks publication rather than scoring the evidence. + assert f"review_body:spec_reference_not_published:{revision}" in checked["errors"] + assert not checked["ok"] and not checked["approval_consistent"] + + # Publishing the same mapping with its revision closes the gap. + result["review_body"] += "Spec revision: " + revision + "\n" + assert check_review_result(packet, result)["ok"]