Skip to content
Merged
8 changes: 6 additions & 2 deletions adapters/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, `threads.py` its review threads, `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
`records.py` the Factory's own run records, `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: items.py, acceptance.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 |
|---|---|
Expand All @@ -32,5 +33,8 @@ Not built yet, by the stories that hold their rules: items.py, acceptance.py, is
| 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 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` |
130 changes: 130 additions & 0 deletions adapters/acceptance.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
"""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 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
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.

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 0047).
"""
from __future__ import annotations

from adapters import timeline
from adapters.github import Unanswered, request
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
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 } labels(first: %d) { totalCount nodes { name } } }
}
}
}
}""" % (PAGE, LABELS)


def closing(transport, pull: dict) -> tuple:
"""`{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"]}
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"]
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
if not isinstance(nodes, list) or not isinstance(total, int) or isinstance(total, bool) or total != len(nodes):
return {}, False
found, whole = {}, True
for node in nodes:
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["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")
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
found[number] = any(n["name"] == terms.LABEL_CONTRACT for n in names)
return found, 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 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
Comment thread
don-petry marked this conversation as resolved.
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}}})
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, 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
14 changes: 8 additions & 6 deletions adapters/facts.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@
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.

One part is not read yet, and says so: the acceptance contract waits on `adapters/acceptance.py`, so `acceptance` is
null and never read whole. A Lens's thread has no category or severity until `harness/` uploads the Finding records they
are joined from (`adapters/threads.py`). The run records are the records port's, read
One part is not read yet, and says so: `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`. A Lens's thread has
no category or severity until `harness/` uploads the Finding records they are joined from (`adapters/threads.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
Expand All @@ -25,7 +26,7 @@
import json
import pathlib

from adapters import checks, pull, threads, timeline
from adapters import acceptance, checks, pull, threads, timeline
from adapters.github import Unanswered
from tools.jsonschema_lite import validate

Expand All @@ -52,6 +53,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)
found, threads_whole = threads.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")
Expand All @@ -74,8 +76,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": threads_whole,
"acceptance": contract,
"complete": {"acceptance": contract_whole, "reviews": reviews_whole, "threads": threads_whole,
"checks": read["complete"], "diff": content["complete"],
"changed_paths": paths["complete"], "runs": runs_whole is True, "timeline": timeline_whole},
"read_at": now,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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/` |
Original file line number Diff line number Diff line change
Expand Up @@ -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/` |
Original file line number Diff line number Diff line change
Expand Up @@ -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/` |
Loading
Loading