From 22cb16851067e5a30edc6accee4f8d56a9743f13 Mon Sep 17 00:00:00 2001 From: Don Petry <36422719+don-petry@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:24:33 -0500 Subject: [PATCH 1/6] Acceptance port: an issue asks for a contract only by an Intent that asks or a person's factory:contract label (#290) First cut; decision 0046, mutants and the review still to come. Co-Authored-By: Claude Opus 5.5 --- adapters/AGENTS.md | 7 +- adapters/acceptance.py | 79 +++++++++++++++ adapters/facts.py | 13 +-- ...n-app-and-only-what-gates-holds-a-merge.md | 6 +- ...kflow-and-only-what-gates-holds-a-merge.md | 6 +- ...ts-on-and-only-what-gates-holds-a-merge.md | 4 +- ...-against-the-contract-a-person-approved.md | 2 +- ...ts-on-and-only-what-gates-holds-a-merge.md | 4 +- kernel/terms.py | 5 + tests/test_adapters.py | 99 ++++++++++++++++++- 10 files changed, 203 insertions(+), 22 deletions(-) create mode 100644 adapters/acceptance.py diff --git a/adapters/AGENTS.md b/adapters/AGENTS.md index deaa0995..9ec08702 100644 --- a/adapters/AGENTS.md +++ b/adapters/AGENTS.md @@ -3,10 +3,11 @@ All of the Factory's I/O. `github.py` is the one transport: every request to GitHub goes through it, and nothing else in this repo reaches the network. `pull.py` reads the facts of a pull request that its own reads give, `timeline.py` its reviews, labels and who acted, `checks.py` its checks and its place in the queue, `facts.py` the one snapshot of it all, `records.py` the Factory's own run records, -and `artifacts.py` a failed Run's outcome from its artifact. A port decides nothing +`artifacts.py` a failed Run's outcome from its artifact, and `acceptance.py` whether the issues it closes ask for a +contract. A port decides nothing and hands the Kernel no text: what it cannot read whole it says is short, and the title travels beside the facts. -Not built yet, by the stories that hold their rules: threads.py, items.py, acceptance.py, issues.py, settings.py, effects.py, usage.py. +Not built yet, by the stories that hold their rules: threads.py, items.py, contract.py, issues.py, settings.py, effects.py, usage.py. | Rule | Held by | |---|---| @@ -29,5 +30,7 @@ Not built yet, by the stories that hold their rules: threads.py, items.py, accep | A review's dismissal, its dismisser and its state before are read from the timeline; no text of GitHub's is kept | `tests/test_adapters.py::test_reviews_are_read_with_their_commit_content_and_who_dismissed_them` | | Every check run on the head is given with its exact name, its App and its workflow file, and none dropped | `tests/test_adapters.py::test_every_check_run_on_the_head_is_read_with_its_name_app_and_workflow` | | The snapshot is refused when the pull request moved while it was read, and is held to `facts/1` | `tests/test_adapters.py::test_a_snapshot_whose_head_moved_while_it_was_read_is_refused` | +| An issue asks for a contract only by an Intent that asks, or the `factory:contract` label a person added; none asks, and `acceptance` is null and whole | `tests/test_adapters.py::test_an_issue_asks_for_a_contract_only_by_a_persons_label` | +| Closing references read short, or of another repo, are not whole | `tests/test_adapters.py::test_closing_references_are_whole_only_of_this_repo_and_every_one_counted` | | The title is handed beside the facts, never in them | `tests/test_adapters.py::test_the_pull_requests_own_facts_and_the_title_beside_them` | | A rule of a part not built yet waits on the file named above, and the story that builds it re-points its rows | `tests/test_tools.py::test_a_wait_on_a_file_a_built_directory_plans_holds_until_the_file_exists` | diff --git a/adapters/acceptance.py b/adapters/acceptance.py new file mode 100644 index 00000000..f7334cc4 --- /dev/null +++ b/adapters/acceptance.py @@ -0,0 +1,79 @@ +"""Whether the issues a pull request closes ask for an acceptance contract, and the contract where one is asked +(design sections 3.13 and 4.8; decision 0046, the Maintainer's rule of 2026-10-21). + +An issue asks for a contract only when its item's Intent is one `kernel/terms.py` names in `CONTRACT_INTENTS`, or when a +person adds the `factory:contract` label to it. No Lifecycle runs those Intents yet, so in slice 1 only the label asks: +the label as it stands after the issue's last event for it, a `labeled` by a person (decision 0005), never one an App or +the machine user added. A pull request that closes no issue asks for none. + +The closing references are read in one GraphQL read, as P-33 and P-46 recorded them, and each closing issue's label +events from its REST timeline, whole by its `link` header, as `adapters/timeline.py` reads a pull request's. The list +is whole only when it holds every reference it counts, each an issue of the pull request's own repo: one in another repo +is read by no Factory App, and is not whole. Where no issue asks, `acceptance` is null and read whole. Where one asks, +the contract is to be read, which `adapters/contract.py`, not built, holds: until then the read is not whole, and the +Kernel defers. +""" +from __future__ import annotations + +from adapters import timeline +from adapters.github import Unanswered, request +from kernel import terms + +PAGE = 10 # closing references on one page, as P-33 and P-46 asked them; more than one page is not read whole +QUERY = """query FactsClosing($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + closingIssuesReferences(first: %d) { totalCount nodes { number repository { nameWithOwner } } } + } + } +}""" % PAGE + + +def closing(transport, pull: dict) -> tuple: + """The numbers of the issues the pull request closes, and whether that list was read whole.""" + repo = pull["base"]["repo"]["full_name"] + owner, name = repo.split("/") + variables = {"owner": owner, "name": name, "number": pull["number"]} + answer = transport.send(request("POST", "/graphql", body={"query": QUERY, "variables": variables})) + if answer["status"] != 200 or not isinstance(answer["body"], dict) or answer["body"].get("errors"): + raise Unanswered("the closing references' read answered %d" % answer["status"]) + pr = (((answer["body"].get("data") or {}).get("repository") or {}).get("pullRequest")) or {} + refs = pr.get("closingIssuesReferences") + nodes = refs.get("nodes") if isinstance(refs, dict) else None + total = refs.get("totalCount") if isinstance(refs, dict) else None + if not isinstance(nodes, list) or not isinstance(total, int) or isinstance(total, bool) or total != len(nodes): + return [], False + numbers, whole = [], True + for node in nodes: + number = node.get("number") if isinstance(node, dict) else None + owned = ((node.get("repository") or {}).get("nameWithOwner") if isinstance(node, dict) else None) + if not isinstance(number, int) or isinstance(number, bool) or number < 1: + raise Unanswered("a closing reference names no issue GitHub gives") + if owned != repo: + whole = False + continue + numbers.append(number) + return numbers, whole and len(set(numbers)) == len(numbers) + + +def asks(transport, repo: str, number: int, accounts: timeline.Accounts) -> tuple: + """Whether the issue asks for a contract by its label, as it stands after its last event for it, added by a person; + and whether its timeline was read whole.""" + events, whole = timeline.events(transport, {"number": number, "base": {"repo": {"full_name": repo}}}) + labeled = [e for e in timeline.label_events(events, accounts) if e["label"] == terms.LABEL_CONTRACT] + if not labeled: + return False, whole + last = labeled[-1] + return last["action"] == "labeled" and last["by"]["kind"] == "human", whole + + +def read(transport, pull: dict, accounts: timeline.Accounts) -> tuple: + """`(acceptance, whole)`: null and whole where no issue the pull request closes asks for a contract; null and not + whole where one asks, or where the closing issues cannot be read whole.""" + numbers, whole = closing(transport, pull) + repo = pull["base"]["repo"]["full_name"] + for number in numbers: + asked, read_whole = asks(transport, repo, number, accounts) + if asked or not read_whole: + return None, False + return None, whole diff --git a/adapters/facts.py b/adapters/facts.py index 37717dd9..e2e7774f 100644 --- a/adapters/facts.py +++ b/adapters/facts.py @@ -8,9 +8,9 @@ may be of the commit between (the known limit; pinning them is still to be built). A part that cannot be read refuses the whole; a list that was not read whole says so in its `complete` flag, and the Kernel defers on it. -Two parts are not read yet, and say so: a thread's state and its Finding's category wait on `adapters/threads.py`, so -`threads` is whole only when the pull request holds none; and the acceptance contract waits on -`adapters/acceptance.py`, so `acceptance` is null and never read whole. The run records are the records port's, read +A part is not read yet, and says so: a thread's state and its Finding's category wait on `adapters/threads.py`, so +`threads` is whole only when the pull request holds none. `acceptance` is null, and whole only where no issue the pull +request closes asks for a contract (`adapters/acceptance.py`): the contract itself waits on `adapters/contract.py`. The run records are the records port's, read once a pass: the caller says whether that read was whole. `read_at` is taken after the last read, never given by the caller. `last_push_at` is the push's own time where the head @@ -25,7 +25,7 @@ import json import pathlib -from adapters import checks, pull, timeline +from adapters import acceptance, checks, pull, timeline from adapters.github import Unanswered from tools.jsonschema_lite import validate @@ -52,6 +52,7 @@ def snapshot(transport, repo: str, number: int, identities: dict, accounts: time writer = timeline.since_writer(events, accounts) labels = timeline.label_events(events, accounts) read = checks.read(transport, first) + contract, contract_whole = acceptance.read(transport, first, accounts) if pusher is not None and pushed_at is None: raise Unanswered("the head's push names its pusher but no time that can be read") if not pull.unchanged(transport, first): @@ -73,8 +74,8 @@ def snapshot(transport, repo: str, number: int, identities: dict, accounts: time "checks": read["checks"], "queue": read["queue"], "acts_since_writer": writer["acts_since_writer"], - "acceptance": None, - "complete": {"acceptance": False, "reviews": reviews_whole, "threads": read["threads"] == 0, + "acceptance": contract, + "complete": {"acceptance": contract_whole, "reviews": reviews_whole, "threads": read["threads"] == 0, "checks": read["complete"], "diff": content["complete"], "changed_paths": paths["complete"], "runs": runs_whole is True, "timeline": timeline_whole}, "read_at": now, diff --git a/docs/decisions/0015-a-lens-is-read-from-its-own-app-and-only-what-gates-holds-a-merge.md b/docs/decisions/0015-a-lens-is-read-from-its-own-app-and-only-what-gates-holds-a-merge.md index abd43e44..7639f5e4 100644 --- a/docs/decisions/0015-a-lens-is-read-from-its-own-app-and-only-what-gates-holds-a-merge.md +++ b/docs/decisions/0015-a-lens-is-read-from-its-own-app-and-only-what-gates-holds-a-merge.md @@ -80,8 +80,8 @@ the deterministic Lenses alone, they are its only judge. | The contract is read after the other three conditions of Convergence and before the approval | `tests/test_decide.py::test_acceptance_is_read_after_every_other_condition_of_convergence` | | A queued item whose evidence is gone, is for other content, fails or cannot be read leaves the merge queue | `tests/test_decide.py::test_a_queued_item_whose_evidence_goes_missing_or_unreadable_leaves_the_queue`, `tests/test_simulator.py::test_a_queued_item_whose_evidence_is_gone_is_taken_out_before_it_merges` | | A wait for evidence is a wait in `verifying`, and goes stale past its limit | `tests/test_decide.py::test_a_wait_for_acceptance_evidence_goes_stale` | -| The port reads the contract at the head, and each result for a criterion with the content it ran on, from the check that gave it | waiting on `adapters/acceptance.py` | -| The port reports the latest result per criterion per check suite | waiting on `adapters/acceptance.py` | -| A result counts only from the App the configuration allows for its criterion's kind; never from the author's Role. Issue 108, "Kernel: acceptance evidence from the right reporter, against the approved contract" | waiting on `adapters/acceptance.py` | +| The port reads the contract at the head, and each result for a criterion with the content it ran on, from the check that gave it | waiting on `adapters/contract.py` | +| The port reports the latest result per criterion per check suite | waiting on `adapters/contract.py` | +| A result counts only from the App the configuration allows for its criterion's kind; never from the author's Role. Issue 108, "Kernel: acceptance evidence from the right reporter, against the approved contract" | waiting on `adapters/contract.py` | | The contract read is the one the Maintainer approved, unchanged since; no fix Run on a contract not yet approved (I-29), in the same story | waiting on `harness/` | | No manifest names another Role's output as a trigger | waiting on `roles/` | diff --git a/docs/decisions/0018-a-lens-is-read-from-its-own-app-and-workflow-and-only-what-gates-holds-a-merge.md b/docs/decisions/0018-a-lens-is-read-from-its-own-app-and-workflow-and-only-what-gates-holds-a-merge.md index a5727270..5d2fb24e 100644 --- a/docs/decisions/0018-a-lens-is-read-from-its-own-app-and-workflow-and-only-what-gates-holds-a-merge.md +++ b/docs/decisions/0018-a-lens-is-read-from-its-own-app-and-workflow-and-only-what-gates-holds-a-merge.md @@ -113,8 +113,8 @@ platform fact not yet tried: the fake GitHub assumes it cannot. | The contract is read after the other three conditions of Convergence and before the approval | `tests/test_decide.py::test_acceptance_is_read_after_every_other_condition_of_convergence` | | A queued item whose evidence is gone, is for other content, fails or cannot be read leaves the merge queue | `tests/test_decide.py::test_a_queued_item_whose_evidence_goes_missing_or_unreadable_leaves_the_queue`, `tests/test_simulator.py::test_a_queued_item_whose_evidence_is_gone_is_taken_out_before_it_merges` | | A wait for evidence is a wait in `verifying`, and goes stale past its limit | `tests/test_decide.py::test_a_wait_for_acceptance_evidence_goes_stale` | -| The port reads the contract at the head, and each result for a criterion with the content it ran on, from the check that gave it | waiting on `adapters/acceptance.py` | -| The port reports the latest result per criterion per check suite | waiting on `adapters/acceptance.py` | -| A result counts only from the App the configuration allows for its criterion's kind; never from the author's Role. Issue 108, "Kernel: acceptance evidence from the right reporter, against the approved contract" | waiting on `adapters/acceptance.py` | +| The port reads the contract at the head, and each result for a criterion with the content it ran on, from the check that gave it | waiting on `adapters/contract.py` | +| The port reports the latest result per criterion per check suite | waiting on `adapters/contract.py` | +| A result counts only from the App the configuration allows for its criterion's kind; never from the author's Role. Issue 108, "Kernel: acceptance evidence from the right reporter, against the approved contract" | waiting on `adapters/contract.py` | | The contract read is the one the Maintainer approved, unchanged since; no fix Run on a contract not yet approved (I-29), in the same story | waiting on `harness/` | | No manifest names another Role's output as a trigger | waiting on `roles/` | diff --git a/docs/decisions/0022-a-lens-is-read-from-its-own-app-and-workflow-its-job-judges-every-job-it-waits-on-and-only-what-gates-holds-a-merge.md b/docs/decisions/0022-a-lens-is-read-from-its-own-app-and-workflow-its-job-judges-every-job-it-waits-on-and-only-what-gates-holds-a-merge.md index 3acca4e6..cd010738 100644 --- a/docs/decisions/0022-a-lens-is-read-from-its-own-app-and-workflow-its-job-judges-every-job-it-waits-on-and-only-what-gates-holds-a-merge.md +++ b/docs/decisions/0022-a-lens-is-read-from-its-own-app-and-workflow-its-job-judges-every-job-it-waits-on-and-only-what-gates-holds-a-merge.md @@ -127,8 +127,8 @@ A job left out of its `needs:` is a check nobody requires, so it needs every oth | The contract is read after the other three conditions of Convergence and before the approval | `tests/test_decide.py::test_acceptance_is_read_after_every_other_condition_of_convergence` | | A queued item whose evidence is gone, is for other content, fails or cannot be read leaves the merge queue | `tests/test_decide.py::test_a_queued_item_whose_evidence_goes_missing_or_unreadable_leaves_the_queue`, `tests/test_simulator.py::test_a_queued_item_whose_evidence_is_gone_is_taken_out_before_it_merges` | | A wait for evidence is a wait in `verifying`, and goes stale past its limit | `tests/test_decide.py::test_a_wait_for_acceptance_evidence_goes_stale` | -| The port reads the contract at the head, and each result for a criterion with the content it ran on, from the check that gave it | waiting on `adapters/acceptance.py` | -| The port reports the latest result per criterion per check suite | waiting on `adapters/acceptance.py` | +| The port reads the contract at the head, and each result for a criterion with the content it ran on, from the check that gave it | waiting on `adapters/contract.py` | +| The port reports the latest result per criterion per check suite | waiting on `adapters/contract.py` | | A result counts only from the App the configuration allows for its criterion's kind; never from the author's Role. Issue 108, "Kernel: acceptance evidence from the right reporter, against the approved contract" | `tests/test_decide.py::test_a_result_counts_only_from_the_reporter_its_kind_names`, `tests/test_decide.py::test_a_kind_with_no_reporter_cannot_pass`, `tests/test_decide.py::test_a_human_criterion_is_passed_only_by_a_persons_approval_of_this_content` | | The contract read is the one the Maintainer approved, unchanged since; no fix Run on a contract not yet approved (I-29), in the same story | `tests/test_decide.py::test_a_contract_no_person_approved_is_a_hold`, `tests/test_decide.py::test_a_contract_changed_since_its_approval_is_a_hold`, `tests/test_decide.py::test_no_run_is_admitted_on_a_contract_not_approved` | | No manifest names another Role's output as a trigger | waiting on `roles/` | diff --git a/docs/decisions/0025-acceptance-is-read-from-its-reporter-against-the-contract-a-person-approved.md b/docs/decisions/0025-acceptance-is-read-from-its-reporter-against-the-contract-a-person-approved.md index dc4171d5..abf0cdff 100644 --- a/docs/decisions/0025-acceptance-is-read-from-its-reporter-against-the-contract-a-person-approved.md +++ b/docs/decisions/0025-acceptance-is-read-from-its-reporter-against-the-contract-a-person-approved.md @@ -80,7 +80,7 @@ P-37 was taken by decision 0023. | A configuration whose `acceptance_reporters` is missing or of another shape defers an item with a contract for the Maintainer | `tests/test_decide.py::test_a_contract_whose_reporters_cannot_be_read_defers` | | The facts carry the approved contract, its digest at the head and each result's check, and refuse any of them missing or a kind the Kernel does not know | `examples/invalid/facts.an-acceptance-contract-without-its-head.json`, `examples/invalid/facts.an-acceptance-contract-approved-by-a-name-alone.json`, `examples/invalid/facts.an-acceptance-criterion-of-a-kind-the-kernel-does-not-know.json`, `examples/invalid/facts.an-acceptance-result-without-its-check.json` | | Under faults, nothing is admitted, converges, queues or merges unless a person approved the contract and the head still carries it, and every criterion passes from its kind's reporter | `tests/test_simulator.py::test_the_seeds_serve_items_with_an_acceptance_contract_and_without`, `tests/test_simulator.py::test_invariants_hold_under_faults` | -| The port reads whether a contract exists from the issue the pull request closes, never the head; the approved contract at the commit of the newest approving review by a person; the digest over the contract and every path it protects; and each result's check | waiting on `adapters/acceptance.py` | +| The port reads whether a contract exists from the issue the pull request closes, never the head; the approved contract at the commit of the newest approving review by a person; the digest over the contract and every path it protects; and each result's check | whether a contract exists: `tests/test_adapters.py::test_an_issue_asks_for_a_contract_only_by_a_persons_label`; the rest is waiting on `adapters/contract.py` | | Whether an approval GitHub dismisses as stale stays on its commit, and who is recorded as dismissing it, is a platform fact, unverified until trial P-38 grounds it | `sim/grounding.py`, `tools/check_trials.py` | | `apply` refuses the implementing Role's change to a contract or a test it names (I-30) | waiting on `harness/` | | A reporter for `role`, `static`, `mutation` and `scenario` | waiting on `harness/` | diff --git a/docs/decisions/0026-a-lens-is-read-from-its-own-app-and-workflow-its-job-judges-every-job-it-waits-on-and-only-what-gates-holds-a-merge.md b/docs/decisions/0026-a-lens-is-read-from-its-own-app-and-workflow-its-job-judges-every-job-it-waits-on-and-only-what-gates-holds-a-merge.md index 897e79db..18f47cb8 100644 --- a/docs/decisions/0026-a-lens-is-read-from-its-own-app-and-workflow-its-job-judges-every-job-it-waits-on-and-only-what-gates-holds-a-merge.md +++ b/docs/decisions/0026-a-lens-is-read-from-its-own-app-and-workflow-its-job-judges-every-job-it-waits-on-and-only-what-gates-holds-a-merge.md @@ -135,8 +135,8 @@ A job left out of its `needs:` is a check nobody requires, so it needs every oth | The contract's evidence is read after the other three conditions of Convergence and before the approval; whether a person approved the contract, unchanged since, and whether it can be read, is read before anything admits a Run, after a check from another App and before a conflict (decision 0025) | `tests/test_decide.py::test_acceptance_is_read_after_every_other_condition_of_convergence`, `tests/test_decide.py::test_no_run_is_admitted_on_a_contract_not_approved`, `tests/test_decide.py::test_an_unreadable_contract_admits_nothing` | | A queued item whose evidence is gone, is for other content, fails or cannot be read leaves the merge queue | `tests/test_decide.py::test_a_queued_item_whose_evidence_goes_missing_or_unreadable_leaves_the_queue`, `tests/test_simulator.py::test_a_queued_item_whose_evidence_is_gone_is_taken_out_before_it_merges` | | A wait for evidence is a wait in `verifying`, and goes stale past its limit | `tests/test_decide.py::test_a_wait_for_acceptance_evidence_goes_stale` | -| The port reads whether there is a contract from the issue the pull request closes, never the head; the approved contract at the commit of the newest approving review by a person; the contract's digest at the head, over every path it protects; and each result for a criterion with the content it ran on, from the check that gave it | waiting on `adapters/acceptance.py` | -| The port reports the latest result per criterion per check suite | waiting on `adapters/acceptance.py` | +| The port reads whether there is a contract from the issue the pull request closes, never the head; the approved contract at the commit of the newest approving review by a person; the contract's digest at the head, over every path it protects; and each result for a criterion with the content it ran on, from the check that gave it | whether a contract exists: `tests/test_adapters.py::test_an_issue_asks_for_a_contract_only_by_a_persons_label`; the rest is waiting on `adapters/contract.py` | +| The port reports the latest result per criterion per check suite | waiting on `adapters/contract.py` | | A result counts only from the App the configuration allows for its criterion's kind; never from the author's Role. Issue 108, "Kernel: acceptance evidence from the right reporter, against the approved contract" | `tests/test_decide.py::test_a_result_counts_only_from_the_reporter_its_kind_names`, `tests/test_decide.py::test_a_kind_with_no_reporter_cannot_pass`, `tests/test_decide.py::test_a_human_criterion_is_passed_only_by_a_persons_approval_of_this_content` | | The contract read is the one the Maintainer approved, unchanged since; no fix Run on a contract not yet approved (I-29), in the same story | `tests/test_decide.py::test_a_contract_no_person_approved_is_a_hold`, `tests/test_decide.py::test_a_contract_changed_since_its_approval_is_a_hold`, `tests/test_decide.py::test_no_run_is_admitted_on_a_contract_not_approved` | | No manifest names another Role's output as a trigger | waiting on `roles/` | diff --git a/kernel/terms.py b/kernel/terms.py index 9a8aaf5c..d06ca371 100644 --- a/kernel/terms.py +++ b/kernel/terms.py @@ -131,3 +131,8 @@ # The labels the Kernel reads on a pull request, and what the timeline records was done to one. CONTROL_LABELS = ("factory:hold", "factory:hands-off", "factory:admit") LABEL_ACTIONS = ("labeled", "unlabeled") +# The label a person adds to an issue to ask for an acceptance contract, and the Intents whose items ask for one +# whatever their labels (the Maintainer's rule of 2026-10-21, decision 0046). The facts port reads both from the issue a +# pull request closes; no Lifecycle runs either Intent yet, so in slice 1 only the label asks. +LABEL_CONTRACT = "factory:contract" +CONTRACT_INTENTS = ("fix", "implement") diff --git a/tests/test_adapters.py b/tests/test_adapters.py index 5068ae11..b6a3d487 100644 --- a/tests/test_adapters.py +++ b/tests/test_adapters.py @@ -15,7 +15,7 @@ import unittest import zipfile -from adapters import artifacts, checks, facts, github, pull, records, timeline +from adapters import acceptance, artifacts, checks, facts, github, pull, records, timeline from tests.support import pending ROOT = pathlib.Path(__file__).resolve().parent.parent @@ -1353,6 +1353,99 @@ def p26_checks(head: str) -> dict: return found +PERSON = ("don-petry", MAINTAINER, "User") # the Maintainer, as the fixtures record him +LENS = ("coderabbitai", 136622811, "Bot") # a Lens's App, as GraphQL names a Bot: its login has no [bot] +APP = ("github-actions", 41898282, "Bot") # an App no Lens is + + +def closing_answer(repo: str, number: int, issues: list, total=None, elsewhere: str = None) -> dict: + """An exchange of the port's closing-references query, in the shape P-33 and P-46 recorded: `totalCount` and each + issue's number, with the repo the port asks for too.""" + owner, name = repo.split("/") + asked = {"query": acceptance.QUERY, "variables": {"owner": owner, "name": name, "number": number}} + nodes = [{"number": n, "repository": {"nameWithOwner": elsewhere or repo}} for n in issues] + refs = {"totalCount": len(nodes) if total is None else total, "nodes": nodes} + return {"request": {"via": "graphql", "call": "POST /graphql", "headers": {}, "body": asked}, + "response": {"status": 200, "headers": {}, + "body": {"data": {"repository": {"pullRequest": {"closingIssuesReferences": refs}}}}}} + + +def issue_timeline(repo: str, number: int, events: list, more: bool = False) -> dict: + """An issue's REST timeline, asked as `adapters/timeline.py` asks a pull request's, in trials/P-28.json's shape.""" + found = copy.deepcopy(exchanges("P-28")[4]) + found["request"]["call"] = "GET /repos/%s/issues/%d/timeline?per_page=100" % (repo, number) + found["response"]["body"] = events + found["response"]["headers"] = dict(found["response"]["headers"], link='; rel="next"' if more else "") + return found + + +def label_event(action: str, actor=PERSON, label: str = "factory:contract", at: str = "2026-10-21T16:00:00Z") -> dict: + login, account, kind = actor + return {"event": action, "label": {"name": label}, "actor": {"login": login, "id": account, "type": kind}, + "created_at": at} + + +class Acceptance(unittest.TestCase): + PULL = {"number": 26, "base": {"repo": {"full_name": "o/r"}}} + + def read(self, *found): + router = Router(github.Replay(list(found)), {"don-petry": "admin"}) + return acceptance.read(router, self.PULL, timeline.Accounts(router, "o/r", MACHINE, {})) + + def test_an_issue_asks_for_a_contract_only_by_a_persons_label(self): + # The Maintainer's rule of 2026-10-21 (decision 0046): a pull request that closes no issue, or one whose issue + # carries no factory:contract a person added, asks for none, and acceptance is null and read whole. + self.assertEqual(self.read(closing_answer("o/r", 26, [])), (None, True)) + self.assertEqual(self.read(closing_answer("o/r", 26, [36]), issue_timeline("o/r", 36, [])), (None, True)) + other = [label_event("labeled", label="bug")] + self.assertEqual(self.read(closing_answer("o/r", 26, [36]), issue_timeline("o/r", 36, other)), (None, True)) + # A person's label asks: the contract is then read, which adapters/contract.py, not built, holds; not whole. + asked = [label_event("labeled")] + self.assertEqual(self.read(closing_answer("o/r", 26, [36]), issue_timeline("o/r", 36, asked)), (None, False)) + # The label as it stands after its last event: removed again, it asks for nothing. + again = [label_event("labeled"), label_event("unlabeled", at="2026-10-21T16:01:00Z")] + self.assertEqual(self.read(closing_answer("o/r", 26, [36]), issue_timeline("o/r", 36, again)), (None, True)) + # Added by an App or the machine user, it asks for nothing: only a person asks. + for actor in (APP, LENS, (APPROVER,) + MACHINE): + self.assertEqual(self.read(closing_answer("o/r", 26, [36]), + issue_timeline("o/r", 36, [label_event("labeled", actor)])), (None, True), actor) + # Of two closing issues, one that asks is enough. + self.assertEqual(self.read(closing_answer("o/r", 26, [36, 37]), issue_timeline("o/r", 36, []), + issue_timeline("o/r", 37, asked)), (None, False)) + # An issue's timeline read short is not whole. + short = issue_timeline("o/r", 36, {"message": "not a list"}) + short["request"]["call"] = "GET /repos/o/r/issues/36/timeline?per_page=100&page=2" + self.assertEqual(self.read(closing_answer("o/r", 26, [36]), issue_timeline("o/r", 36, [], more=True), short), + (None, False)) + + def test_closing_references_are_whole_only_of_this_repo_and_every_one_counted(self): + self.assertEqual(self.read(closing_answer("o/r", 26, [36], total=11)), (None, False)) + self.assertEqual(self.read(closing_answer("o/r", 26, [36], elsewhere="o/other")), (None, False)) + self.assertEqual(self.read(closing_answer("o/r", 26, [36, 36]), issue_timeline("o/r", 36, []), + issue_timeline("o/r", 36, [])), (None, False)) + for broken in ({"number": 0}, {"number": True}, {"number": "36"}, "36"): + found = closing_answer("o/r", 26, [36]) + found["response"]["body"]["data"]["repository"]["pullRequest"]["closingIssuesReferences"]["nodes"] = [broken] + with self.assertRaises(github.Unanswered, msg=broken): + self.read(found) + for refs in (None, {"totalCount": None, "nodes": []}, {"totalCount": 0, "nodes": None}, + {"totalCount": True, "nodes": [{"number": 36, "repository": {"nameWithOwner": "o/r"}}]}): + found = closing_answer("o/r", 26, []) + found["response"]["body"]["data"]["repository"]["pullRequest"]["closingIssuesReferences"] = refs + self.assertEqual(self.read(found), (None, False), refs) + for status, answer_body in ((502, None), (200, {"errors": [{"message": "x"}]})): + failed = closing_answer("o/r", 26, []) + failed["response"].update(status=status, body=answer_body) + with self.assertRaises(github.Unanswered): + self.read(failed) + + def test_no_intent_that_asks_for_a_contract_runs_yet(self): + # An issue asks by its Intent only once a Lifecycle runs one that asks: until the port reads an item's Intent, + # adding such an Intent to a Lifecycle fails here. + from kernel import issue, terms + self.assertEqual(set(terms.CONTRACT_INTENTS) & (set(issue.INTENTS) | set(records.ON_PULL_REQUEST)), set()) + + class Snapshot(unittest.TestCase): NOW = "2026-10-18T03:00:00Z" @@ -1363,7 +1456,7 @@ def sequence(self, reread: int) -> list: found = exchanges("P-29.base") head = found[1]["response"]["body"]["head"]["sha"] return [found[1], found[9], exchanges("P-28")[3], found[2], p26_files(), exchanges("P-28")[4], p26_reviews(), - p26_checks(head), found[reread]] + p26_checks(head), closing_answer(canary(), 26, []), found[reread]] def read(self, reread: int): router = Router(github.Replay(self.sequence(reread)), {"don-petry": "admin"}) @@ -1379,7 +1472,7 @@ def test_a_snapshot_holds_every_part_of_the_facts_and_the_title_beside_them(self # The activity read is of a later push than the head the pull request was read at: no pusher, and the push's # time is the read's, so nothing reads as placed after a push it could not see. self.assertEqual((got["last_pusher"], got["last_push_at"]), (None, self.NOW)) - self.assertEqual(got["complete"], {"acceptance": False, "reviews": True, "threads": True, "checks": True, + self.assertEqual(got["complete"], {"acceptance": True, "reviews": True, "threads": True, "checks": True, "diff": True, "changed_paths": True, "runs": True, "timeline": True}) self.assertIsNone(got["acceptance"]) self.assertNotIn(title, json.dumps(got)) From 55244abfd28cc4566942d81e8b6976844f3b17e4 Mon Sep 17 00:00:00 2001 From: Don Petry <36422719+don-petry@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:29:36 -0500 Subject: [PATCH 2/6] Acceptance port: decision 0046, the rows and the mutants (#290) - decision 0046 adds to 0025: when an issue asks for a contract, and what is read whole when none does - the contract rows of 0015, 0018, 0022, 0025 and 0026 wait on adapters/contract.py; whether one is asked is held - a snapshot whose closing issue asks is not whole on acceptance, tested; the acceptance mutants killed by hand Co-Authored-By: Claude Opus 5.5 --- ...-an-intent-that-asks-or-a-persons-label.md | 27 +++++++++++++ tests/test_adapters.py | 9 +++++ tools/mutants.py | 38 ++++++++++++++++++- 3 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 docs/decisions/0046-an-issue-asks-for-an-acceptance-contract-only-by-an-intent-that-asks-or-a-persons-label.md diff --git a/docs/decisions/0046-an-issue-asks-for-an-acceptance-contract-only-by-an-intent-that-asks-or-a-persons-label.md b/docs/decisions/0046-an-issue-asks-for-an-acceptance-contract-only-by-an-intent-that-asks-or-a-persons-label.md new file mode 100644 index 00000000..f38ad491 --- /dev/null +++ b/docs/decisions/0046-an-issue-asks-for-an-acceptance-contract-only-by-an-intent-that-asks-or-a-persons-label.md @@ -0,0 +1,27 @@ +# 0046. An issue asks for an acceptance contract only by an Intent that asks, or a person's `factory:contract` label + +Status: accepted. The Maintainer's rule of 2026-10-21, adopted on "Facts port: one snapshot of a pull request from GitHub, with its completeness flags" (issue 191), and built for "Facts port: an issue asks for an acceptance contract only by its Intent or a person's factory:contract label; acceptance read whole when none asks" (issue 290). It adds to `docs/decisions/0025-acceptance-is-read-from-its-reporter-against-the-contract-a-person-approved.md`, whose contract is read from the issue a pull request closes, and says when that issue asks for one. + +## Decision + +| What | Decided | Because | +|---|---|---| +| When an issue asks for a contract | Only when its item's Intent is one that asks, `fix` or `implement` (`kernel/terms.py`'s `CONTRACT_INTENTS`), or when a person adds the `factory:contract` label to it: the label as it stands after the issue's last event for it, a `labeled` by a person, never by an App or the machine user | A contract is a person's statement of what done means; no rule said when one is owed, so every pull request deferred on a contract that could never be read | +| Slice 1 | No Lifecycle runs an Intent that asks, so only the label asks; a test fails when such an Intent is added before the port reads an item's Intent | The first approval must not wait on a contract no story in slice 1 writes | +| A pull request that closes no issue, or whose issues none ask | `acceptance` is null, read whole | Nothing asked for a contract, so there is none to read | +| An issue that asks | The contract is read; until the part of the port that reads it is built, the read is not whole, and the Kernel defers | A contract asked for and not read is never read as no contract | +| What is not whole | A list of closing references longer than its page, one of another repo, one named twice, or an issue's timeline read short | A read that cannot say every issue was read cannot say none asked | + +## Why + +Design sections 3.13 and 4.8 make the acceptance contract the item's, read from the issue the pull request closes, but say only that the Kernel reads `acceptance` null "when the issue the pull request closes asks for none". Without a rule for asking, the port could never read `acceptance` whole, and the Kernel, rightly, deferred on every pull request. The label is a person's act on the issue, read by account and never by name (decision 0005); the Intents are the ones whose work a contract describes. + +## Boundaries + +| Boundary | Check | +|---|---| +| An issue asks only by the `factory:contract` label a person added, as it stands after its last event; none asks, and `acceptance` is null and whole; one asks, and it is not whole | `tests/test_adapters.py::test_an_issue_asks_for_a_contract_only_by_a_persons_label` | +| Closing references read short, of another repo, or named twice are not whole | `tests/test_adapters.py::test_closing_references_are_whole_only_of_this_repo_and_every_one_counted` | +| No Lifecycle runs an Intent that asks before the port reads an item's Intent | `tests/test_adapters.py::test_no_intent_that_asks_for_a_contract_runs_yet` | +| A snapshot whose pull request closes no issue that asks reads `complete.acceptance` true | `tests/test_adapters.py::test_a_snapshot_holds_every_part_of_the_facts_and_the_title_beside_them` | +| The contract asked for is read | waiting on `adapters/contract.py` | diff --git a/tests/test_adapters.py b/tests/test_adapters.py index b6a3d487..ed6e71d5 100644 --- a/tests/test_adapters.py +++ b/tests/test_adapters.py @@ -1477,6 +1477,15 @@ def test_a_snapshot_holds_every_part_of_the_facts_and_the_title_beside_them(self self.assertIsNone(got["acceptance"]) self.assertNotIn(title, json.dumps(got)) + def test_a_snapshot_whose_closing_issue_asks_for_a_contract_is_not_read_whole_on_it(self): + seq = self.sequence(1) + at = len(seq) - 2 + seq[at:at + 1] = [closing_answer(canary(), 26, [36]), issue_timeline(canary(), 36, [label_event("labeled")])] + router = Router(github.Replay(seq), {"don-petry": "admin"}) + got, _ = facts.snapshot(router, canary(), 26, person(), timeline.Accounts(router, canary(), MACHINE, {}), True, + lambda: self.NOW) + self.assertEqual((got["acceptance"], got["complete"]["acceptance"]), (None, False)) + def test_the_bases_head_and_a_pushs_time_are_read_or_refused(self): self.assertEqual(pull.base(replay("P-29.base", 9), body("P-29.base", 1))["head"], body("P-29.base", 9)["commit"]["sha"]) diff --git a/tools/mutants.py b/tools/mutants.py index a8c71c6e..8fef8c7a 100644 --- a/tools/mutants.py +++ b/tools/mutants.py @@ -1686,6 +1686,42 @@ def moved_before(rel: str, block: str, anchor: str) -> tuple: ('a malformed queue entry is read', 'adapters/checks.py', ' if not (entry is None or isinstance(entry, dict)):\n', ' if False:\n', ('tests.test_adapters',)), + ('a label anyone added asks for a contract', 'adapters/acceptance.py', + 'return last["action"] == "labeled" and last["by"]["kind"] == "human", whole', + 'return last["action"] == "labeled", whole', ('tests.test_adapters',)), + ('a label removed again still asks', 'adapters/acceptance.py', + 'return last["action"] == "labeled" and last["by"]["kind"] == "human", whole', + 'return last["by"]["kind"] == "human", whole', ('tests.test_adapters',)), + ('the first label event is read, not the last', 'adapters/acceptance.py', + ' last = labeled[-1]\n', + ' last = labeled[0]\n', ('tests.test_adapters',)), + ('any label asks for a contract', 'adapters/acceptance.py', + ' if e["label"] == terms.LABEL_CONTRACT]', + ']', ('tests.test_adapters',)), + ('an issue that asks is read whole', 'adapters/acceptance.py', + ' if asked or not read_whole:\n return None, False\n', + ' if not read_whole:\n return None, False\n', ('tests.test_adapters',)), + ("an issue's short timeline is whole", 'adapters/acceptance.py', + ' if asked or not read_whole:\n', + ' if asked:\n', ('tests.test_adapters',)), + ('closing references of another repo are whole', 'adapters/acceptance.py', + ' if owned != repo:\n whole = False\n', + ' if owned != repo:\n whole = True\n', ('tests.test_adapters',)), + ('closing references past the page are whole', 'adapters/acceptance.py', + ' or total != len(nodes):\n return [], False', + ':\n return [], False', ('tests.test_adapters',)), + ('a closing reference named twice is whole', 'adapters/acceptance.py', + ' return numbers, whole and len(set(numbers)) == len(numbers)', + ' return numbers, whole', ('tests.test_adapters',)), + ('a closing reference that names no issue is read', 'adapters/acceptance.py', + ' if not isinstance(number, int) or isinstance(number, bool) or number < 1:\n', + ' if False:\n', ('tests.test_adapters',)), + ('a closing references answer that is no answer is read', 'adapters/acceptance.py', + ' if answer["status"] != 200 or not isinstance(answer["body"], dict) or answer["body"].get("errors"):\n raise Unanswered("the closing', + ' if False:\n raise Unanswered("the closing', ('tests.test_adapters',)), + ('acceptance is never read whole', 'adapters/facts.py', + '"complete": {"acceptance": contract_whole,', + '"complete": {"acceptance": False,', ('tests.test_adapters',)), ('a status context is a check', 'adapters/checks.py', ' if node.get("__typename") == "CheckRun":\n', ' if node.get("__typename") in ("CheckRun", "StatusContext"):\n', ('tests.test_adapters',)), @@ -1747,7 +1783,7 @@ def moved_before(rel: str, block: str, anchor: str) -> tuple: ' if not pull.unchanged(transport, first):\n', ' if False:\n', ('tests.test_adapters',)), ('acceptance is read whole', 'adapters/facts.py', - '"complete": {"acceptance": False,', + '"complete": {"acceptance": contract_whole,', '"complete": {"acceptance": True,', ('tests.test_adapters',)), ('threads are whole whatever their count', 'adapters/facts.py', '"threads": read["threads"] == 0,', From 4e45302d470cd149ba4b845abc5118209ff642f8 Mon Sep 17 00:00:00 2001 From: Don Petry <36422719+don-petry@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:51:14 -0500 Subject: [PATCH 3/6] Acceptance port: the contract label read as a control label, only a person ends it; another base is not whole (#290) The review and security pass's fix round: - the label binds until a person removes it (L-17), as standing_labels reads a control label; an App acting with a person's token is no person, and a same-second removal reads as placed - the labels GitHub lists on the issue must agree with its timeline, else not whole - a pull request to a base other than the default carries no closing reference (P-33): not whole - every Intent a Lifecycle runs is pinned in a test; the unused CONTRACT_INTENTS is gone - decision 0046 states the author-written closing link as the known limit, and its rows name every test Co-Authored-By: Claude Opus 5.5 --- adapters/AGENTS.md | 5 +- adapters/acceptance.py | 112 ++++++++++++------ ...-an-intent-that-asks-or-a-persons-label.md | 15 ++- kernel/terms.py | 6 +- tests/test_adapters.py | 110 +++++++++++------ tools/mutants.py | 69 +++++++---- 6 files changed, 211 insertions(+), 106 deletions(-) diff --git a/adapters/AGENTS.md b/adapters/AGENTS.md index 9ec08702..f667e870 100644 --- a/adapters/AGENTS.md +++ b/adapters/AGENTS.md @@ -30,7 +30,8 @@ Not built yet, by the stories that hold their rules: threads.py, items.py, contr | A review's dismissal, its dismisser and its state before are read from the timeline; no text of GitHub's is kept | `tests/test_adapters.py::test_reviews_are_read_with_their_commit_content_and_who_dismissed_them` | | Every check run on the head is given with its exact name, its App and its workflow file, and none dropped | `tests/test_adapters.py::test_every_check_run_on_the_head_is_read_with_its_name_app_and_workflow` | | The snapshot is refused when the pull request moved while it was read, and is held to `facts/1` | `tests/test_adapters.py::test_a_snapshot_whose_head_moved_while_it_was_read_is_refused` | -| An issue asks for a contract only by an Intent that asks, or the `factory:contract` label a person added; none asks, and `acceptance` is null and whole | `tests/test_adapters.py::test_an_issue_asks_for_a_contract_only_by_a_persons_label` | -| Closing references read short, or of another repo, are not whole | `tests/test_adapters.py::test_closing_references_are_whole_only_of_this_repo_and_every_one_counted` | +| An issue asks for a contract only by an Intent that asks or a person's `factory:contract` label; if none asks, `acceptance` is null and whole | `tests/test_adapters.py::test_an_issue_asks_for_a_contract_only_by_a_persons_label` | +| Closing references read short, of another repo or named twice, are not whole | `tests/test_adapters.py::test_closing_references_are_whole_only_of_this_repo_and_every_one_counted` | +| A pull request to another base than the default is not whole on its contract | `tests/test_adapters.py::test_a_pull_request_to_another_base_than_the_default_is_not_read_whole_on_its_contract` | | The title is handed beside the facts, never in them | `tests/test_adapters.py::test_the_pull_requests_own_facts_and_the_title_beside_them` | | A rule of a part not built yet waits on the file named above, and the story that builds it re-points its rows | `tests/test_tools.py::test_a_wait_on_a_file_a_built_directory_plans_holds_until_the_file_exists` | diff --git a/adapters/acceptance.py b/adapters/acceptance.py index f7334cc4..0109b981 100644 --- a/adapters/acceptance.py +++ b/adapters/acceptance.py @@ -1,36 +1,51 @@ """Whether the issues a pull request closes ask for an acceptance contract, and the contract where one is asked (design sections 3.13 and 4.8; decision 0046, the Maintainer's rule of 2026-10-21). -An issue asks for a contract only when its item's Intent is one `kernel/terms.py` names in `CONTRACT_INTENTS`, or when a -person adds the `factory:contract` label to it. No Lifecycle runs those Intents yet, so in slice 1 only the label asks: -the label as it stands after the issue's last event for it, a `labeled` by a person (decision 0005), never one an App or -the machine user added. A pull request that closes no issue asks for none. +An issue asks for a contract only when its item's Intent is one that asks, `fix` or `implement`, or when a person adds +the `factory:contract` label to it. No Lifecycle runs an Intent that asks yet, so in slice 1 only the label asks. The +label is read as the Kernel reads a control label (`kernel/derive.py`'s `standing_labels`): it binds from when it is +added until a person removes it, so what a person placed only a person ends (L-17), and anyone else placing it again +changes nothing; a removal in the same second is read the way that asks. It asks only when a person, an account with +write access and no App acting for it, placed it (decision 0005). The issue's labels as GitHub lists them must agree +with its timeline: a label the timeline does not account for, renamed into the name or placed out of sight, is not read +whole. -The closing references are read in one GraphQL read, as P-33 and P-46 recorded them, and each closing issue's label -events from its REST timeline, whole by its `link` header, as `adapters/timeline.py` reads a pull request's. The list -is whole only when it holds every reference it counts, each an issue of the pull request's own repo: one in another repo -is read by no Factory App, and is not whole. Where no issue asks, `acceptance` is null and read whole. Where one asks, -the contract is to be read, which `adapters/contract.py`, not built, holds: until then the read is not whole, and the -Kernel defers. +A pull request to a base other than the repo's default branch carries no closing reference at all (P-33), so whether +its issue asks cannot be read: it is not whole. The closing references are read in one GraphQL read, `totalCount` and +each issue's number as P-33 and P-46 recorded them, with each issue's repo and labels, which no trial has recorded yet; +each closing issue's label events from its REST timeline, whole by its `link` header, as `adapters/timeline.py` reads a +pull request's. The list is whole only when it holds every reference it counts, once each, every one an issue of the +pull request's own repo. + +Where no issue asks, `acceptance` is null and read whole. Where one asks, the contract is to be read, which +`adapters/contract.py`, not built, holds: until then the read is not whole, and the Kernel defers. + +The known limit: the closing references are the pull request's own, which its author writes, so an author who removes +"Closes" before a pass removes the ask (decision 0046). """ from __future__ import annotations from adapters import timeline from adapters.github import Unanswered, request -from kernel import terms +from kernel import gate, terms -PAGE = 10 # closing references on one page, as P-33 and P-46 asked them; more than one page is not read whole +PAGE = 10 # closing references on one page, as P-33 and P-46 asked them; more than one page is not read whole +LABELS = 50 # an issue's labels on one page; more is not read whole QUERY = """query FactsClosing($owner: String!, $name: String!, $number: Int!) { repository(owner: $owner, name: $name) { pullRequest(number: $number) { - closingIssuesReferences(first: %d) { totalCount nodes { number repository { nameWithOwner } } } + closingIssuesReferences(first: %d) { + totalCount + nodes { number repository { nameWithOwner } labels(first: %d) { totalCount nodes { name } } } + } } } -}""" % PAGE +}""" % (PAGE, LABELS) def closing(transport, pull: dict) -> tuple: - """The numbers of the issues the pull request closes, and whether that list was read whole.""" + """`{number: whether GitHub lists the label on it}` for each issue the pull request closes, and whether that list + was read whole.""" repo = pull["base"]["repo"]["full_name"] owner, name = repo.split("/") variables = {"owner": owner, "name": name, "number": pull["number"]} @@ -42,38 +57,67 @@ def closing(transport, pull: dict) -> tuple: nodes = refs.get("nodes") if isinstance(refs, dict) else None total = refs.get("totalCount") if isinstance(refs, dict) else None if not isinstance(nodes, list) or not isinstance(total, int) or isinstance(total, bool) or total != len(nodes): - return [], False - numbers, whole = [], True + return {}, False + found, whole = {}, True for node in nodes: number = node.get("number") if isinstance(node, dict) else None - owned = ((node.get("repository") or {}).get("nameWithOwner") if isinstance(node, dict) else None) if not isinstance(number, int) or isinstance(number, bool) or number < 1: raise Unanswered("a closing reference names no issue GitHub gives") - if owned != repo: + owned = (node.get("repository") or {}).get("nameWithOwner") + labels = node.get("labels") if isinstance(node.get("labels"), dict) else {} + names = labels.get("nodes") if isinstance(labels.get("nodes"), list) else None + counted = labels.get("totalCount") + if owned != repo or number in found or names is None or counted != len(names) \ + or not all(isinstance(n, dict) and isinstance(n.get("name"), str) for n in names): whole = False continue - numbers.append(number) - return numbers, whole and len(set(numbers)) == len(numbers) + found[number] = any(n["name"] == terms.LABEL_CONTRACT for n in names) + return found, whole -def asks(transport, repo: str, number: int, accounts: timeline.Accounts) -> tuple: - """Whether the issue asks for a contract by its label, as it stands after its last event for it, added by a person; - and whether its timeline was read whole.""" +def placed(events: list, accounts: timeline.Accounts): + """Who placed the `factory:contract` label as it stands after the issue's timeline, as `standing_labels` reads a + control label, or None where it does not stand. An act an App took with a person's token is no person's.""" + standing = None + found = [] + for event in events: + event = timeline._object(event, "the timeline") + if event.get("event") not in timeline.LABELS: + continue + label = event.get("label") if isinstance(event.get("label"), dict) else {} + if label.get("name") != terms.LABEL_CONTRACT: + continue + by = accounts.actor(event.get("actor")) + person = gate.is_person(by) and not event.get("performed_via_github_app") + found.append((timeline._time(event.get("created_at")), event["event"] == "labeled", person)) + # In the order they happened; of two in the same second, the removal first, so the label is read as placed. + for _, labeled, person in sorted(found, key=lambda f: (f[0], f[1])): + if labeled: + if not (standing is not None and standing and not person): + standing = person + elif person: + standing = None + return standing + + +def asks(transport, repo: str, number: int, listed: bool, accounts: timeline.Accounts) -> tuple: + """Whether the issue asks for a contract by its label, a person's placement that stands; and whether that was read + whole: its timeline read whole, and agreeing with whether GitHub lists the label on it.""" events, whole = timeline.events(transport, {"number": number, "base": {"repo": {"full_name": repo}}}) - labeled = [e for e in timeline.label_events(events, accounts) if e["label"] == terms.LABEL_CONTRACT] - if not labeled: - return False, whole - last = labeled[-1] - return last["action"] == "labeled" and last["by"]["kind"] == "human", whole + standing = placed(events, accounts) + return standing is True, whole and (standing is not None) == listed def read(transport, pull: dict, accounts: timeline.Accounts) -> tuple: """`(acceptance, whole)`: null and whole where no issue the pull request closes asks for a contract; null and not - whole where one asks, or where the closing issues cannot be read whole.""" - numbers, whole = closing(transport, pull) - repo = pull["base"]["repo"]["full_name"] - for number in numbers: - asked, read_whole = asks(transport, repo, number, accounts) + whole where one asks, where the closing issues cannot be read whole, or where the base is not the default branch, + whose pull requests carry no closing reference to read.""" + base = pull["base"] + if not base.get("ref") or base.get("ref") != (base.get("repo") or {}).get("default_branch"): + return None, False + found, whole = closing(transport, pull) + for number, listed in found.items(): + asked, read_whole = asks(transport, base["repo"]["full_name"], number, listed, accounts) if asked or not read_whole: return None, False return None, whole diff --git a/docs/decisions/0046-an-issue-asks-for-an-acceptance-contract-only-by-an-intent-that-asks-or-a-persons-label.md b/docs/decisions/0046-an-issue-asks-for-an-acceptance-contract-only-by-an-intent-that-asks-or-a-persons-label.md index f38ad491..c2361952 100644 --- a/docs/decisions/0046-an-issue-asks-for-an-acceptance-contract-only-by-an-intent-that-asks-or-a-persons-label.md +++ b/docs/decisions/0046-an-issue-asks-for-an-acceptance-contract-only-by-an-intent-that-asks-or-a-persons-label.md @@ -6,11 +6,12 @@ Status: accepted. The Maintainer's rule of 2026-10-21, adopted on "Facts port: o | What | Decided | Because | |---|---|---| -| When an issue asks for a contract | Only when its item's Intent is one that asks, `fix` or `implement` (`kernel/terms.py`'s `CONTRACT_INTENTS`), or when a person adds the `factory:contract` label to it: the label as it stands after the issue's last event for it, a `labeled` by a person, never by an App or the machine user | A contract is a person's statement of what done means; no rule said when one is owed, so every pull request deferred on a contract that could never be read | -| Slice 1 | No Lifecycle runs an Intent that asks, so only the label asks; a test fails when such an Intent is added before the port reads an item's Intent | The first approval must not wait on a contract no story in slice 1 writes | +| When an issue asks for a contract | Only when its item's Intent is one that asks, `fix` or `implement`, or when a person adds the `factory:contract` label (`kernel/terms.py`) to it. The label is read as the Kernel reads a control label (`standing_labels`): it binds from when it is added until a person removes it, so what a person placed only a person ends (L-17), and a removal in the same second reads as placed. It asks only where a person, with write access and no App acting for it, placed it. The labels GitHub lists on the issue must agree with its timeline | A contract is a person's statement of what done means; no rule said when one is owed, so every pull request deferred on a contract that could never be read | +| Slice 1 | No Lifecycle runs an Intent named `fix` or `implement`: `fix-findings` fixes a pull request's Findings, not an issue's item. Every Intent a Lifecycle runs is named in a test, so a new one is ruled on before it runs | The first approval must not wait on a contract no story in slice 1 writes | | A pull request that closes no issue, or whose issues none ask | `acceptance` is null, read whole | Nothing asked for a contract, so there is none to read | | An issue that asks | The contract is read; until the part of the port that reads it is built, the read is not whole, and the Kernel defers | A contract asked for and not read is never read as no contract | -| What is not whole | A list of closing references longer than its page, one of another repo, one named twice, or an issue's timeline read short | A read that cannot say every issue was read cannot say none asked | +| What is not whole | A list of closing references longer than its page, one of another repo, one named twice, an issue whose labels are more than a page or disagree with its timeline, an issue's timeline read short, and a pull request to a base other than the default branch, which carries no closing reference (P-33) | A read that cannot say every issue was read cannot say none asked | +| The known limit | The closing references are the pull request's own, which its author writes: an author who removes "Closes" before a pass removes the ask. Binding a pull request to its issue by its Run record or its branch, never its body, is a later story's | The rule says when an issue asks; which issue a pull request answers to is its author's to say until a Run opens the pull request itself | ## Why @@ -20,8 +21,10 @@ Design sections 3.13 and 4.8 make the acceptance contract the item's, read from | Boundary | Check | |---|---| -| An issue asks only by the `factory:contract` label a person added, as it stands after its last event; none asks, and `acceptance` is null and whole; one asks, and it is not whole | `tests/test_adapters.py::test_an_issue_asks_for_a_contract_only_by_a_persons_label` | +| An issue asks only by the `factory:contract` label a person placed, which only a person ends; none asks, and `acceptance` is null and whole; one asks, and it is not whole | `tests/test_adapters.py::test_an_issue_asks_for_a_contract_only_by_a_persons_label` | +| An issue whose labels disagree with its timeline, or whose timeline is short, is not whole | `tests/test_adapters.py::test_an_issue_whose_labels_and_timeline_disagree_or_are_short_is_not_whole` | | Closing references read short, of another repo, or named twice are not whole | `tests/test_adapters.py::test_closing_references_are_whole_only_of_this_repo_and_every_one_counted` | -| No Lifecycle runs an Intent that asks before the port reads an item's Intent | `tests/test_adapters.py::test_no_intent_that_asks_for_a_contract_runs_yet` | -| A snapshot whose pull request closes no issue that asks reads `complete.acceptance` true | `tests/test_adapters.py::test_a_snapshot_holds_every_part_of_the_facts_and_the_title_beside_them` | +| A pull request to another base than the default is not whole on its contract | `tests/test_adapters.py::test_a_pull_request_to_another_base_than_the_default_is_not_read_whole_on_its_contract` | +| Every Intent a Lifecycle runs is ruled on whether it asks | `tests/test_adapters.py::test_every_intent_a_lifecycle_runs_is_ruled_on_whether_it_asks_for_a_contract` | +| A snapshot reads `complete.acceptance` true where no closing issue asks, and false where one does | `tests/test_adapters.py::test_a_snapshot_holds_every_part_of_the_facts_and_the_title_beside_them`, `tests/test_adapters.py::test_a_snapshot_whose_closing_issue_asks_for_a_contract_is_not_read_whole_on_it` | | The contract asked for is read | waiting on `adapters/contract.py` | diff --git a/kernel/terms.py b/kernel/terms.py index d06ca371..eb87ea33 100644 --- a/kernel/terms.py +++ b/kernel/terms.py @@ -131,8 +131,6 @@ # The labels the Kernel reads on a pull request, and what the timeline records was done to one. CONTROL_LABELS = ("factory:hold", "factory:hands-off", "factory:admit") LABEL_ACTIONS = ("labeled", "unlabeled") -# The label a person adds to an issue to ask for an acceptance contract, and the Intents whose items ask for one -# whatever their labels (the Maintainer's rule of 2026-10-21, decision 0046). The facts port reads both from the issue a -# pull request closes; no Lifecycle runs either Intent yet, so in slice 1 only the label asks. +# The label a person adds to an issue to ask for an acceptance contract (decision 0046): the facts port reads it from the +# issue a pull request closes, as the Kernel reads a control label. No Lifecycle runs an Intent that asks yet. LABEL_CONTRACT = "factory:contract" -CONTRACT_INTENTS = ("fix", "implement") diff --git a/tests/test_adapters.py b/tests/test_adapters.py index ed6e71d5..b54d7c08 100644 --- a/tests/test_adapters.py +++ b/tests/test_adapters.py @@ -1358,19 +1358,22 @@ def p26_checks(head: str) -> dict: APP = ("github-actions", 41898282, "Bot") # an App no Lens is -def closing_answer(repo: str, number: int, issues: list, total=None, elsewhere: str = None) -> dict: - """An exchange of the port's closing-references query, in the shape P-33 and P-46 recorded: `totalCount` and each - issue's number, with the repo the port asks for too.""" +def closing_answer(repo: str, number: int, issues: list, total=None, elsewhere: str = None, labelled=()) -> dict: + """An exchange of the port's closing-references query: `totalCount` and each issue's number, in the shape P-33 and + P-46 recorded, with each issue's repo and labels, which no trial has recorded yet. `labelled` are the issues GitHub + lists the factory:contract label on.""" owner, name = repo.split("/") asked = {"query": acceptance.QUERY, "variables": {"owner": owner, "name": name, "number": number}} - nodes = [{"number": n, "repository": {"nameWithOwner": elsewhere or repo}} for n in issues] + nodes = [{"number": n, "repository": {"nameWithOwner": elsewhere or repo}, + "labels": {"totalCount": 1 if n in labelled else 0, + "nodes": [{"name": "factory:contract"}] if n in labelled else []}} for n in issues] refs = {"totalCount": len(nodes) if total is None else total, "nodes": nodes} return {"request": {"via": "graphql", "call": "POST /graphql", "headers": {}, "body": asked}, "response": {"status": 200, "headers": {}, "body": {"data": {"repository": {"pullRequest": {"closingIssuesReferences": refs}}}}}} -def issue_timeline(repo: str, number: int, events: list, more: bool = False) -> dict: +def issue_timeline(repo: str, number: int, events, more: bool = False) -> dict: """An issue's REST timeline, asked as `adapters/timeline.py` asks a pull request's, in trials/P-28.json's shape.""" found = copy.deepcopy(exchanges("P-28")[4]) found["request"]["call"] = "GET /repos/%s/issues/%d/timeline?per_page=100" % (repo, number) @@ -1379,71 +1382,106 @@ def issue_timeline(repo: str, number: int, events: list, more: bool = False) -> return found -def label_event(action: str, actor=PERSON, label: str = "factory:contract", at: str = "2026-10-21T16:00:00Z") -> dict: +def label_event(action: str, actor=PERSON, label: str = "factory:contract", at: str = "2026-10-21T16:00:00Z", + via=None) -> dict: login, account, kind = actor return {"event": action, "label": {"name": label}, "actor": {"login": login, "id": account, "type": kind}, - "created_at": at} + "created_at": at, "performed_via_github_app": via} class Acceptance(unittest.TestCase): - PULL = {"number": 26, "base": {"repo": {"full_name": "o/r"}}} + PULL = {"number": 26, "base": {"ref": "main", "repo": {"full_name": "o/r", "default_branch": "main"}}} - def read(self, *found): - router = Router(github.Replay(list(found)), {"don-petry": "admin"}) - return acceptance.read(router, self.PULL, timeline.Accounts(router, "o/r", MACHINE, {})) + def read(self, *found, pull=None): + router = Router(github.Replay(list(found)), {"don-petry": "admin", "reader": "read"}) + return acceptance.read(router, pull or self.PULL, timeline.Accounts(router, "o/r", MACHINE, {})) + + def asked(self, events, listed=True): + """What one closing issue with these label events, and the label GitHub lists or not, reads as.""" + return self.read(closing_answer("o/r", 26, [36], labelled=[36] if listed else []), + issue_timeline("o/r", 36, events)) def test_an_issue_asks_for_a_contract_only_by_a_persons_label(self): # The Maintainer's rule of 2026-10-21 (decision 0046): a pull request that closes no issue, or one whose issue - # carries no factory:contract a person added, asks for none, and acceptance is null and read whole. + # carries no factory:contract a person placed, asks for none, and acceptance is null and read whole. self.assertEqual(self.read(closing_answer("o/r", 26, [])), (None, True)) - self.assertEqual(self.read(closing_answer("o/r", 26, [36]), issue_timeline("o/r", 36, [])), (None, True)) - other = [label_event("labeled", label="bug")] - self.assertEqual(self.read(closing_answer("o/r", 26, [36]), issue_timeline("o/r", 36, other)), (None, True)) + self.assertEqual(self.asked([], listed=False), (None, True)) + self.assertEqual(self.asked([label_event("labeled", label="bug")], listed=False), (None, True)) # A person's label asks: the contract is then read, which adapters/contract.py, not built, holds; not whole. - asked = [label_event("labeled")] - self.assertEqual(self.read(closing_answer("o/r", 26, [36]), issue_timeline("o/r", 36, asked)), (None, False)) - # The label as it stands after its last event: removed again, it asks for nothing. - again = [label_event("labeled"), label_event("unlabeled", at="2026-10-21T16:01:00Z")] - self.assertEqual(self.read(closing_answer("o/r", 26, [36]), issue_timeline("o/r", 36, again)), (None, True)) - # Added by an App or the machine user, it asks for nothing: only a person asks. - for actor in (APP, LENS, (APPROVER,) + MACHINE): - self.assertEqual(self.read(closing_answer("o/r", 26, [36]), - issue_timeline("o/r", 36, [label_event("labeled", actor)])), (None, True), actor) + self.assertEqual(self.asked([label_event("labeled")]), (None, False)) + # Only a person ends what a person placed (L-17): removed by an App or the machine user, it still asks. + later = "2026-10-21T16:01:00Z" + self.assertEqual(self.asked([label_event("labeled"), label_event("unlabeled", at=later)], listed=False), + (None, True)) + for actor in (APP, (APPROVER,) + MACHINE): + self.assertEqual(self.asked([label_event("labeled"), label_event("unlabeled", actor, at=later)], + listed=False), (None, False), actor) + # Placed by an App, the machine user, a reader, or an App with a person's token, it asks for nothing. + for event in (label_event("labeled", APP), label_event("labeled", (APPROVER,) + MACHINE), + label_event("labeled", ("reader", 8, "User")), label_event("labeled", via={"slug": "x"})): + self.assertEqual(self.asked([event]), (None, True), event) + # Placed again by an App over a person's, the person's placement stands; after a person removed it, an App's + # placement asks for nothing. + self.assertEqual(self.asked([label_event("labeled"), label_event("labeled", APP, at=later)]), (None, False)) + self.assertEqual(self.asked([label_event("labeled"), label_event("unlabeled", at=later), + label_event("labeled", APP, at="2026-10-21T16:02:00Z")]), (None, True)) + # A person's placement and removal in the same second read as placed, the reading that asks. + self.assertEqual(self.asked([label_event("labeled"), label_event("unlabeled")], listed=False), (None, False)) # Of two closing issues, one that asks is enough. - self.assertEqual(self.read(closing_answer("o/r", 26, [36, 37]), issue_timeline("o/r", 36, []), - issue_timeline("o/r", 37, asked)), (None, False)) - # An issue's timeline read short is not whole. + self.assertEqual(self.read(closing_answer("o/r", 26, [36, 37], labelled=[37]), issue_timeline("o/r", 36, []), + issue_timeline("o/r", 37, [label_event("labeled")])), (None, False)) + + def test_an_issue_whose_labels_and_timeline_disagree_or_are_short_is_not_whole(self): + # GitHub lists the label, but no event placed it (renamed into the name, or placed out of sight); or the + # timeline says it stands and GitHub lists none. + self.assertEqual(self.asked([], listed=True), (None, False)) + self.assertEqual(self.asked([label_event("labeled", APP)], listed=False), (None, False)) short = issue_timeline("o/r", 36, {"message": "not a list"}) short["request"]["call"] = "GET /repos/o/r/issues/36/timeline?per_page=100&page=2" self.assertEqual(self.read(closing_answer("o/r", 26, [36]), issue_timeline("o/r", 36, [], more=True), short), (None, False)) def test_closing_references_are_whole_only_of_this_repo_and_every_one_counted(self): + self.assertIn("closingIssuesReferences(first: 10)", acceptance.QUERY) self.assertEqual(self.read(closing_answer("o/r", 26, [36], total=11)), (None, False)) self.assertEqual(self.read(closing_answer("o/r", 26, [36], elsewhere="o/other")), (None, False)) - self.assertEqual(self.read(closing_answer("o/r", 26, [36, 36]), issue_timeline("o/r", 36, []), - issue_timeline("o/r", 36, [])), (None, False)) + self.assertEqual(self.read(closing_answer("o/r", 26, [36, 36]), issue_timeline("o/r", 36, [])), (None, False)) + many = closing_answer("o/r", 26, [36]) + many["response"]["body"]["data"]["repository"]["pullRequest"]["closingIssuesReferences"]["nodes"][0]["labels"] \ + = {"totalCount": 51, "nodes": []} + self.assertEqual(self.read(many), (None, False)) for broken in ({"number": 0}, {"number": True}, {"number": "36"}, "36"): found = closing_answer("o/r", 26, [36]) found["response"]["body"]["data"]["repository"]["pullRequest"]["closingIssuesReferences"]["nodes"] = [broken] with self.assertRaises(github.Unanswered, msg=broken): self.read(found) for refs in (None, {"totalCount": None, "nodes": []}, {"totalCount": 0, "nodes": None}, - {"totalCount": True, "nodes": [{"number": 36, "repository": {"nameWithOwner": "o/r"}}]}): + {"totalCount": True, "nodes": closing_answer("o/r", 26, [36])["response"]["body"]["data"][ + "repository"]["pullRequest"]["closingIssuesReferences"]["nodes"]}): found = closing_answer("o/r", 26, []) found["response"]["body"]["data"]["repository"]["pullRequest"]["closingIssuesReferences"] = refs self.assertEqual(self.read(found), (None, False), refs) - for status, answer_body in ((502, None), (200, {"errors": [{"message": "x"}]})): + for status, answer_body in ((502, None), (502, {"data": {}}), (200, {"errors": [{"message": "x"}]})): failed = closing_answer("o/r", 26, []) failed["response"].update(status=status, body=answer_body) with self.assertRaises(github.Unanswered): self.read(failed) - def test_no_intent_that_asks_for_a_contract_runs_yet(self): - # An issue asks by its Intent only once a Lifecycle runs one that asks: until the port reads an item's Intent, - # adding such an Intent to a Lifecycle fails here. - from kernel import issue, terms - self.assertEqual(set(terms.CONTRACT_INTENTS) & (set(issue.INTENTS) | set(records.ON_PULL_REQUEST)), set()) + def test_a_pull_request_to_another_base_than_the_default_is_not_read_whole_on_its_contract(self): + # trials/P-33.json: a pull request to a base that is not the default branch carries no closing reference at + # all, so whether its issue asks cannot be read. + for base in ({"ref": "release", "repo": {"full_name": "o/r", "default_branch": "main"}}, + {"ref": "main", "repo": {"full_name": "o/r"}}, {"repo": {"full_name": "o/r"}}): + self.assertEqual(self.read(pull={"number": 26, "base": base}), (None, False), base) + + def test_every_intent_a_lifecycle_runs_is_ruled_on_whether_it_asks_for_a_contract(self): + # Decision 0046: an issue asks by its item's Intent only for `fix` or `implement`, which no Lifecycle runs. + # Every Intent a Lifecycle runs is named here, none of them one that asks: a new one fails here until it is + # ruled whether it asks, and the port reads it if it does. + from kernel import issue + self.assertEqual(set(issue.INTENTS) | set(records.ON_PULL_REQUEST), + {"investigate-issue", "judge-readiness", "open-pull-request", "review-change", "fix-findings", + "adjudicate-findings", "issue-approval"}) class Snapshot(unittest.TestCase): diff --git a/tools/mutants.py b/tools/mutants.py index 8fef8c7a..175bd5ee 100644 --- a/tools/mutants.py +++ b/tools/mutants.py @@ -1686,42 +1686,63 @@ def moved_before(rel: str, block: str, anchor: str) -> tuple: ('a malformed queue entry is read', 'adapters/checks.py', ' if not (entry is None or isinstance(entry, dict)):\n', ' if False:\n', ('tests.test_adapters',)), - ('a label anyone added asks for a contract', 'adapters/acceptance.py', - 'return last["action"] == "labeled" and last["by"]["kind"] == "human", whole', - 'return last["action"] == "labeled", whole', ('tests.test_adapters',)), - ('a label removed again still asks', 'adapters/acceptance.py', - 'return last["action"] == "labeled" and last["by"]["kind"] == "human", whole', - 'return last["by"]["kind"] == "human", whole', ('tests.test_adapters',)), - ('the first label event is read, not the last', 'adapters/acceptance.py', - ' last = labeled[-1]\n', - ' last = labeled[0]\n', ('tests.test_adapters',)), - ('any label asks for a contract', 'adapters/acceptance.py', - ' if e["label"] == terms.LABEL_CONTRACT]', - ']', ('tests.test_adapters',)), + ('acceptance is never read whole', 'adapters/facts.py', + '"complete": {"acceptance": contract_whole,', + '"complete": {"acceptance": False,', ('tests.test_adapters',)), + ("anyone ends a person's contract label", 'adapters/acceptance.py', + ' elif person:\n standing = None\n', + ' else:\n standing = None\n', ('tests.test_adapters',)), + ("an App's placement replaces a person's", 'adapters/acceptance.py', + ' if not (standing is not None and standing and not person):\n', + ' if True:\n', ('tests.test_adapters',)), + ('a same-second removal reads as removed', 'adapters/acceptance.py', + 'key=lambda f: (f[0], f[1])', + 'key=lambda f: (f[0], not f[1])', ('tests.test_adapters',)), + ("an App acting with a person's token is a person", 'adapters/acceptance.py', + ' and not event.get("performed_via_github_app")', + '', ('tests.test_adapters',)), + ("a reader's label asks", 'adapters/acceptance.py', + 'person = gate.is_person(by) and', + 'person = by["kind"] == "human" and', ('tests.test_adapters',)), + ('any label is the contract label', 'adapters/acceptance.py', + ' if label.get("name") != terms.LABEL_CONTRACT:\n continue\n', + '', ('tests.test_adapters',)), + ("an App's standing label asks", 'adapters/acceptance.py', + ' return standing is True, whole and', + ' return standing is not None, whole and', ('tests.test_adapters',)), + ('labels that disagree with the timeline are whole', 'adapters/acceptance.py', + ' return standing is True, whole and (standing is not None) == listed', + ' return standing is True, whole', ('tests.test_adapters',)), ('an issue that asks is read whole', 'adapters/acceptance.py', ' if asked or not read_whole:\n return None, False\n', ' if not read_whole:\n return None, False\n', ('tests.test_adapters',)), - ("an issue's short timeline is whole", 'adapters/acceptance.py', + ("an issue's short read is whole", 'adapters/acceptance.py', ' if asked or not read_whole:\n', ' if asked:\n', ('tests.test_adapters',)), + ('another base than the default is read whole', 'adapters/acceptance.py', + ' if not base.get("ref") or base.get("ref") != (base.get("repo") or {}).get("default_branch"):\n return None, False\n', + '', ('tests.test_adapters',)), ('closing references of another repo are whole', 'adapters/acceptance.py', - ' if owned != repo:\n whole = False\n', - ' if owned != repo:\n whole = True\n', ('tests.test_adapters',)), - ('closing references past the page are whole', 'adapters/acceptance.py', - ' or total != len(nodes):\n return [], False', - ':\n return [], False', ('tests.test_adapters',)), + ' if owned != repo or number in found', + ' if number in found', ('tests.test_adapters',)), ('a closing reference named twice is whole', 'adapters/acceptance.py', - ' return numbers, whole and len(set(numbers)) == len(numbers)', - ' return numbers, whole', ('tests.test_adapters',)), + 'if owned != repo or number in found or names', + 'if owned != repo or names', ('tests.test_adapters',)), + ("an issue's labels past their page are whole", 'adapters/acceptance.py', + ' or counted != len(names) \\\n', + ' \\\n', ('tests.test_adapters',)), + ('closing references past the page are whole', 'adapters/acceptance.py', + ' or total != len(nodes):\n return {}, False', + ':\n return {}, False', ('tests.test_adapters',)), ('a closing reference that names no issue is read', 'adapters/acceptance.py', ' if not isinstance(number, int) or isinstance(number, bool) or number < 1:\n', ' if False:\n', ('tests.test_adapters',)), ('a closing references answer that is no answer is read', 'adapters/acceptance.py', ' if answer["status"] != 200 or not isinstance(answer["body"], dict) or answer["body"].get("errors"):\n raise Unanswered("the closing', - ' if False:\n raise Unanswered("the closing', ('tests.test_adapters',)), - ('acceptance is never read whole', 'adapters/facts.py', - '"complete": {"acceptance": contract_whole,', - '"complete": {"acceptance": False,', ('tests.test_adapters',)), + ' if not isinstance(answer["body"], dict) or answer["body"].get("errors"):\n raise Unanswered("the closing', ('tests.test_adapters',)), + ('more closing references are asked than a page', 'adapters/acceptance.py', + 'PAGE = 10 ', + 'PAGE = 20 ', ('tests.test_adapters',)), ('a status context is a check', 'adapters/checks.py', ' if node.get("__typename") == "CheckRun":\n', ' if node.get("__typename") in ("CheckRun", "StatusContext"):\n', ('tests.test_adapters',)), From 7be5456563ae7ed53874e9676a1de32d57bb3ad8 Mon Sep 17 00:00:00 2001 From: Don Petry <36422719+don-petry@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:16:43 -0500 Subject: [PATCH 4/6] Acceptance port: a malformed closing read refused as Unanswered; the binding to its issue filed as #295 (#290) - Muse's read: a closing read of a shape GitHub does not give is refused, never another error; tested, a mutant - the 502 case holds a whole body, so the status check alone refuses it - 0046 names #295 as its known limit's story, and its row waits on adapters/binding.py Co-Authored-By: Claude Opus 5.5 --- adapters/AGENTS.md | 2 +- adapters/acceptance.py | 8 ++++++-- ...-by-an-intent-that-asks-or-a-persons-label.md | 3 ++- tests/test_adapters.py | 16 +++++++++++++++- tools/mutants.py | 3 +++ 5 files changed, 27 insertions(+), 5 deletions(-) diff --git a/adapters/AGENTS.md b/adapters/AGENTS.md index 77f4f235..12a30ec6 100644 --- a/adapters/AGENTS.md +++ b/adapters/AGENTS.md @@ -7,7 +7,7 @@ this repo reaches the network. `pull.py` reads the facts of a pull request that whether the issues it closes ask for a contract. A port decides nothing and hands the Kernel no text: what it cannot read whole it says is short, and the title travels beside the facts. -Not built yet, by the stories that hold their rules: items.py, contract.py, issues.py, settings.py, effects.py, usage.py. +Not built yet, by the stories that hold their rules: items.py, contract.py, binding.py, issues.py, settings.py, effects.py, usage.py. | Rule | Held by | |---|---| diff --git a/adapters/acceptance.py b/adapters/acceptance.py index 0109b981..e0176d16 100644 --- a/adapters/acceptance.py +++ b/adapters/acceptance.py @@ -52,7 +52,11 @@ def closing(transport, pull: dict) -> tuple: answer = transport.send(request("POST", "/graphql", body={"query": QUERY, "variables": variables})) if answer["status"] != 200 or not isinstance(answer["body"], dict) or answer["body"].get("errors"): raise Unanswered("the closing references' read answered %d" % answer["status"]) - pr = (((answer["body"].get("data") or {}).get("repository") or {}).get("pullRequest")) or {} + pr = answer["body"] + for key in ("data", "repository", "pullRequest"): + pr = pr.get(key) if isinstance(pr, dict) else None + if not isinstance(pr, dict): + raise Unanswered("the closing references' read gave no pull request") refs = pr.get("closingIssuesReferences") nodes = refs.get("nodes") if isinstance(refs, dict) else None total = refs.get("totalCount") if isinstance(refs, dict) else None @@ -63,7 +67,7 @@ def closing(transport, pull: dict) -> tuple: number = node.get("number") if isinstance(node, dict) else None if not isinstance(number, int) or isinstance(number, bool) or number < 1: raise Unanswered("a closing reference names no issue GitHub gives") - owned = (node.get("repository") or {}).get("nameWithOwner") + owned = node["repository"].get("nameWithOwner") if isinstance(node.get("repository"), dict) else None labels = node.get("labels") if isinstance(node.get("labels"), dict) else {} names = labels.get("nodes") if isinstance(labels.get("nodes"), list) else None counted = labels.get("totalCount") diff --git a/docs/decisions/0046-an-issue-asks-for-an-acceptance-contract-only-by-an-intent-that-asks-or-a-persons-label.md b/docs/decisions/0046-an-issue-asks-for-an-acceptance-contract-only-by-an-intent-that-asks-or-a-persons-label.md index c2361952..241fadd4 100644 --- a/docs/decisions/0046-an-issue-asks-for-an-acceptance-contract-only-by-an-intent-that-asks-or-a-persons-label.md +++ b/docs/decisions/0046-an-issue-asks-for-an-acceptance-contract-only-by-an-intent-that-asks-or-a-persons-label.md @@ -11,7 +11,7 @@ Status: accepted. The Maintainer's rule of 2026-10-21, adopted on "Facts port: o | A pull request that closes no issue, or whose issues none ask | `acceptance` is null, read whole | Nothing asked for a contract, so there is none to read | | An issue that asks | The contract is read; until the part of the port that reads it is built, the read is not whole, and the Kernel defers | A contract asked for and not read is never read as no contract | | What is not whole | A list of closing references longer than its page, one of another repo, one named twice, an issue whose labels are more than a page or disagree with its timeline, an issue's timeline read short, and a pull request to a base other than the default branch, which carries no closing reference (P-33) | A read that cannot say every issue was read cannot say none asked | -| The known limit | The closing references are the pull request's own, which its author writes: an author who removes "Closes" before a pass removes the ask. Binding a pull request to its issue by its Run record or its branch, never its body, is a later story's | The rule says when an issue asks; which issue a pull request answers to is its author's to say until a Run opens the pull request itself | +| The known limit | The closing references are the pull request's own, which its author writes: an author who removes "Closes" before a pass removes the ask. Binding a pull request to its issue by its Run record or its branch, never its body, is "Acceptance: a pull request is bound to the issue it serves by its Run or branch, not by its author's closing link" (issue 295) | The rule says when an issue asks; which issue a pull request answers to is its author's to say until a Run opens the pull request itself | ## Why @@ -27,4 +27,5 @@ Design sections 3.13 and 4.8 make the acceptance contract the item's, read from | A pull request to another base than the default is not whole on its contract | `tests/test_adapters.py::test_a_pull_request_to_another_base_than_the_default_is_not_read_whole_on_its_contract` | | Every Intent a Lifecycle runs is ruled on whether it asks | `tests/test_adapters.py::test_every_intent_a_lifecycle_runs_is_ruled_on_whether_it_asks_for_a_contract` | | A snapshot reads `complete.acceptance` true where no closing issue asks, and false where one does | `tests/test_adapters.py::test_a_snapshot_holds_every_part_of_the_facts_and_the_title_beside_them`, `tests/test_adapters.py::test_a_snapshot_whose_closing_issue_asks_for_a_contract_is_not_read_whole_on_it` | +| A pull request is bound to the issue it serves by its Run or branch, not by its author's closing link | waiting on `adapters/binding.py` | | The contract asked for is read | waiting on `adapters/contract.py` | diff --git a/tests/test_adapters.py b/tests/test_adapters.py index f51c06f6..33cf3b88 100644 --- a/tests/test_adapters.py +++ b/tests/test_adapters.py @@ -1457,8 +1457,22 @@ def test_closing_references_are_whole_only_of_this_repo_and_every_one_counted(se found = closing_answer("o/r", 26, []) found["response"]["body"]["data"]["repository"]["pullRequest"]["closingIssuesReferences"] = refs self.assertEqual(self.read(found), (None, False), refs) - for status, answer_body in ((502, None), (502, {"data": {}}), (200, {"errors": [{"message": "x"}]})): + # A shape GitHub does not give is refused, never let through as another error. + for shape in ({"data": "x"}, {"data": {"repository": []}}, {"data": {"repository": {"pullRequest": "x"}}}): + odd = closing_answer("o/r", 26, []) + odd["response"]["body"] = shape + with self.assertRaises(github.Unanswered, msg=shape): + self.read(odd) + stringly = closing_answer("o/r", 26, [36]) + stringly["response"]["body"]["data"]["repository"]["pullRequest"]["closingIssuesReferences"]["nodes"][0][ + "repository"] = "o/r" + self.assertEqual(self.read(stringly), (None, False)) + whole_body = closing_answer("o/r", 26, [])["response"]["body"] + for status, answer_body in ((502, None), (502, whole_body), (200, {"errors": [{"message": "x"}]})): failed = closing_answer("o/r", 26, []) + failed["response"].update(status=status, body=answer_body) + with self.assertRaises(github.Unanswered, msg=status): + self.read(failed) def test_a_pull_request_to_another_base_than_the_default_is_not_read_whole_on_its_contract(self): # trials/P-33.json: a pull request to a base that is not the default branch carries no closing reference at diff --git a/tools/mutants.py b/tools/mutants.py index 806b2b3c..c504f4f9 100644 --- a/tools/mutants.py +++ b/tools/mutants.py @@ -1800,6 +1800,9 @@ def moved_before(rel: str, block: str, anchor: str) -> tuple: ('a threads answer that is no answer is read', 'adapters/threads.py', ' if answer["status"] != 200 or not isinstance(answer["body"], dict) or answer["body"].get("errors"):\n raise Unanswered("the threads', ' if False:\n raise Unanswered("the threads', ('tests.test_adapters',)), + ('a closing read with no pull request is read as none', 'adapters/acceptance.py', + ' if not isinstance(pr, dict):\n raise Unanswered("the closing references\' read gave no pull request")\n', + ' if not isinstance(pr, dict):\n pr = {}\n', ('tests.test_adapters',)), ('a status context is a check', 'adapters/checks.py', ' if node.get("__typename") == "CheckRun":\n', ' if node.get("__typename") in ("CheckRun", "StatusContext"):\n', ('tests.test_adapters',)), From 3100186fb65c12a8f36222d31d06953a211b950e Mon Sep 17 00:00:00 2001 From: Don Petry <36422719+don-petry@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:37:05 -0500 Subject: [PATCH 5/6] Acceptance port: its record is decision 0047, as the queue's order sets (#290) #293 keeps 0046, this takes 0047, #298 takes 0048 (the orchestrator, 2026-10-21). Co-Authored-By: Claude Opus 5.5 --- adapters/acceptance.py | 4 ++-- ...ontract-only-by-an-intent-that-asks-or-a-persons-label.md} | 2 +- kernel/terms.py | 2 +- tests/test_adapters.py | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) rename docs/decisions/{0046-an-issue-asks-for-an-acceptance-contract-only-by-an-intent-that-asks-or-a-persons-label.md => 0047-an-issue-asks-for-an-acceptance-contract-only-by-an-intent-that-asks-or-a-persons-label.md} (98%) diff --git a/adapters/acceptance.py b/adapters/acceptance.py index e0176d16..61ddc431 100644 --- a/adapters/acceptance.py +++ b/adapters/acceptance.py @@ -1,5 +1,5 @@ """Whether the issues a pull request closes ask for an acceptance contract, and the contract where one is asked -(design sections 3.13 and 4.8; decision 0046, the Maintainer's rule of 2026-10-21). +(design sections 3.13 and 4.8; decision 0047, the Maintainer's rule of 2026-10-21). An issue asks for a contract only when its item's Intent is one that asks, `fix` or `implement`, or when a person adds the `factory:contract` label to it. No Lifecycle runs an Intent that asks yet, so in slice 1 only the label asks. The @@ -21,7 +21,7 @@ `adapters/contract.py`, not built, holds: until then the read is not whole, and the Kernel defers. The known limit: the closing references are the pull request's own, which its author writes, so an author who removes -"Closes" before a pass removes the ask (decision 0046). +"Closes" before a pass removes the ask (decision 0047). """ from __future__ import annotations diff --git a/docs/decisions/0046-an-issue-asks-for-an-acceptance-contract-only-by-an-intent-that-asks-or-a-persons-label.md b/docs/decisions/0047-an-issue-asks-for-an-acceptance-contract-only-by-an-intent-that-asks-or-a-persons-label.md similarity index 98% rename from docs/decisions/0046-an-issue-asks-for-an-acceptance-contract-only-by-an-intent-that-asks-or-a-persons-label.md rename to docs/decisions/0047-an-issue-asks-for-an-acceptance-contract-only-by-an-intent-that-asks-or-a-persons-label.md index 241fadd4..4f07075b 100644 --- a/docs/decisions/0046-an-issue-asks-for-an-acceptance-contract-only-by-an-intent-that-asks-or-a-persons-label.md +++ b/docs/decisions/0047-an-issue-asks-for-an-acceptance-contract-only-by-an-intent-that-asks-or-a-persons-label.md @@ -1,4 +1,4 @@ -# 0046. An issue asks for an acceptance contract only by an Intent that asks, or a person's `factory:contract` label +# 0047. An issue asks for an acceptance contract only by an Intent that asks, or a person's `factory:contract` label Status: accepted. The Maintainer's rule of 2026-10-21, adopted on "Facts port: one snapshot of a pull request from GitHub, with its completeness flags" (issue 191), and built for "Facts port: an issue asks for an acceptance contract only by its Intent or a person's factory:contract label; acceptance read whole when none asks" (issue 290). It adds to `docs/decisions/0025-acceptance-is-read-from-its-reporter-against-the-contract-a-person-approved.md`, whose contract is read from the issue a pull request closes, and says when that issue asks for one. diff --git a/kernel/terms.py b/kernel/terms.py index eb87ea33..ea7b8ea2 100644 --- a/kernel/terms.py +++ b/kernel/terms.py @@ -131,6 +131,6 @@ # The labels the Kernel reads on a pull request, and what the timeline records was done to one. CONTROL_LABELS = ("factory:hold", "factory:hands-off", "factory:admit") LABEL_ACTIONS = ("labeled", "unlabeled") -# The label a person adds to an issue to ask for an acceptance contract (decision 0046): the facts port reads it from the +# The label a person adds to an issue to ask for an acceptance contract (decision 0047): the facts port reads it from the # issue a pull request closes, as the Kernel reads a control label. No Lifecycle runs an Intent that asks yet. LABEL_CONTRACT = "factory:contract" diff --git a/tests/test_adapters.py b/tests/test_adapters.py index 33cf3b88..08966095 100644 --- a/tests/test_adapters.py +++ b/tests/test_adapters.py @@ -1398,7 +1398,7 @@ def asked(self, events, listed=True): issue_timeline("o/r", 36, events)) def test_an_issue_asks_for_a_contract_only_by_a_persons_label(self): - # The Maintainer's rule of 2026-10-21 (decision 0046): a pull request that closes no issue, or one whose issue + # The Maintainer's rule of 2026-10-21 (decision 0047): a pull request that closes no issue, or one whose issue # carries no factory:contract a person placed, asks for none, and acceptance is null and read whole. self.assertEqual(self.read(closing_answer("o/r", 26, [])), (None, True)) self.assertEqual(self.asked([], listed=False), (None, True)) @@ -1482,7 +1482,7 @@ def test_a_pull_request_to_another_base_than_the_default_is_not_read_whole_on_it self.assertEqual(self.read(pull={"number": 26, "base": base}), (None, False), base) def test_every_intent_a_lifecycle_runs_is_ruled_on_whether_it_asks_for_a_contract(self): - # Decision 0046: an issue asks by its item's Intent only for `fix` or `implement`, which no Lifecycle runs. + # Decision 0047: an issue asks by its item's Intent only for `fix` or `implement`, which no Lifecycle runs. # Every Intent a Lifecycle runs is named here, none of them one that asks: a new one fails here until it is # ruled whether it asks, and the port reads it if it does. from kernel import issue From 358bff1029cc36e8fc771f2bf5d9f014c05e5f7d Mon Sep 17 00:00:00 2001 From: Don Petry <36422719+don-petry@users.noreply.github.com> Date: Wed, 21 Oct 2026 19:37:04 -0500 Subject: [PATCH 6/6] Acceptance port: a label count that is no count, and a label event that names no label, are refused (#290) Codex's two threads on #296. Co-Authored-By: Claude Opus 5.5 --- adapters/acceptance.py | 7 +++++-- tests/test_adapters.py | 12 ++++++++++++ tools/mutants.py | 8 +++++++- 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/adapters/acceptance.py b/adapters/acceptance.py index 61ddc431..1115b33a 100644 --- a/adapters/acceptance.py +++ b/adapters/acceptance.py @@ -71,7 +71,8 @@ def closing(transport, pull: dict) -> tuple: labels = node.get("labels") if isinstance(node.get("labels"), dict) else {} names = labels.get("nodes") if isinstance(labels.get("nodes"), list) else None counted = labels.get("totalCount") - if owned != repo or number in found or names is None or counted != len(names) \ + if owned != repo or number in found or names is None or not isinstance(counted, int) \ + or isinstance(counted, bool) or counted != len(names) \ or not all(isinstance(n, dict) and isinstance(n.get("name"), str) for n in names): whole = False continue @@ -89,7 +90,9 @@ def placed(events: list, accounts: timeline.Accounts): if event.get("event") not in timeline.LABELS: continue label = event.get("label") if isinstance(event.get("label"), dict) else {} - if label.get("name") != terms.LABEL_CONTRACT: + if not isinstance(label.get("name"), str) or not label["name"]: + raise Unanswered("a label event names no label") + if label["name"] != terms.LABEL_CONTRACT: continue by = accounts.actor(event.get("actor")) person = gate.is_person(by) and not event.get("performed_via_github_app") diff --git a/tests/test_adapters.py b/tests/test_adapters.py index e05014ed..9b93e4e9 100644 --- a/tests/test_adapters.py +++ b/tests/test_adapters.py @@ -1529,6 +1529,13 @@ def test_an_issue_asks_for_a_contract_only_by_a_persons_label(self): self.assertEqual(self.read(closing_answer("o/r", 26, [36, 37], labelled=[37]), issue_timeline("o/r", 36, []), issue_timeline("o/r", 37, [label_event("labeled")])), (None, False)) + def test_a_label_event_that_names_no_label_is_refused(self): + # It could be the contract label's placement or removal: refused, as timeline.label_events refuses it. + for label in (None, {}, {"name": ""}, {"name": 7}, "factory:contract"): + event = dict(label_event("labeled"), label=label) + with self.assertRaises(github.Unanswered, msg=label): + self.asked([event], listed=False) + def test_an_issue_whose_labels_and_timeline_disagree_or_are_short_is_not_whole(self): # GitHub lists the label, but no event placed it (renamed into the name, or placed out of sight); or the # timeline says it stands and GitHub lists none. @@ -1548,6 +1555,11 @@ def test_closing_references_are_whole_only_of_this_repo_and_every_one_counted(se many["response"]["body"]["data"]["repository"]["pullRequest"]["closingIssuesReferences"]["nodes"][0]["labels"] \ = {"totalCount": 51, "nodes": []} self.assertEqual(self.read(many), (None, False)) + # A label count that is no count is not read whole, though True equals one. + boolean = closing_answer("o/r", 26, [36], labelled=[36]) + boolean["response"]["body"]["data"]["repository"]["pullRequest"]["closingIssuesReferences"]["nodes"][0][ + "labels"]["totalCount"] = True + self.assertEqual(self.read(boolean), (None, False)) for broken in ({"number": 0}, {"number": True}, {"number": "36"}, "36"): found = closing_answer("o/r", 26, [36]) found["response"]["body"]["data"]["repository"]["pullRequest"]["closingIssuesReferences"]["nodes"] = [broken] diff --git a/tools/mutants.py b/tools/mutants.py index bbc13c1c..27a02048 100644 --- a/tools/mutants.py +++ b/tools/mutants.py @@ -1705,7 +1705,7 @@ def moved_before(rel: str, block: str, anchor: str) -> tuple: 'person = gate.is_person(by) and', 'person = by["kind"] == "human" and', ('tests.test_adapters',)), ('any label is the contract label', 'adapters/acceptance.py', - ' if label.get("name") != terms.LABEL_CONTRACT:\n continue\n', + ' if label["name"] != terms.LABEL_CONTRACT:\n continue\n', '', ('tests.test_adapters',)), ("an App's standing label asks", 'adapters/acceptance.py', ' return standing is True, whole and', @@ -1803,6 +1803,12 @@ def moved_before(rel: str, block: str, anchor: str) -> tuple: ('a closing read with no pull request is read as none', 'adapters/acceptance.py', ' if not isinstance(pr, dict):\n raise Unanswered("the closing references\' read gave no pull request")\n', ' if not isinstance(pr, dict):\n pr = {}\n', ('tests.test_adapters',)), + ('a label count that is no count is read', 'adapters/acceptance.py', + ' or not isinstance(counted, int) \\\n or isinstance(counted, bool) or counted', + ' or counted', ('tests.test_adapters',)), + ('a label event that names no label is skipped', 'adapters/acceptance.py', + ' if not isinstance(label.get("name"), str) or not label["name"]:\n raise Unanswered("a label event names no label")\n', + ' if not isinstance(label.get("name"), str) or not label["name"]:\n continue\n', ('tests.test_adapters',)), ('a status context is a check', 'adapters/checks.py', ' if node.get("__typename") == "CheckRun":\n', ' if node.get("__typename") in ("CheckRun", "StatusContext"):\n', ('tests.test_adapters',)),