You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Kernel: derive an issue's State from its facts #41
Part of the epic "Epic: slice 0, foundations. The repo holds the line, its context is tested, and the platform facts are recorded".
Why. The issue Lifecycle was reviewed three times as prose, and each round of fixes introduced new faults: 15, then 12, then 8. Nothing in prose executes. The pull request's Lifecycle found its two design errors only when it ran. This is the first of three stories that make the issue Lifecycle run, decided by the Maintainer on 2026-10-11.
Done when
An issue's facts have a schema, an example and counter-examples, with the fields §3.3 lists; no field holds prose.
The ten rows of the derivation, with "accepted", "linked" and "a label counts", are one pure function in the Kernel. It does no I/O and reads no clock.
The 35 situations the reviews said went wrong are its boundary tests, written first. They are in the comment on the design's pull request that holds derive_issue_model.py; that script is their seed and is not copied in as it stands.
Each row has a mutant, and every mutant is killed.
Needs. The design has merged, and the story "The issue Lifecycle as data, checked for termination". The facts are the simulator's and the tests' to invent; trials P-32 to P-34 say later whether GitHub can supply them.
Design. §4.1, the issue Lifecycle, "How the State is derived"; §3.3; I-39, I-41, I-44.
Solution Architect, delegated. On main at 3d4109c (#145).
0. The lane holds.#41 edits nothing in kernel/decide.py, kernel/derive.py or kernel/terms.py. An issue's State comes from one new function, derive(facts, runs, config) in kernel/issue.py. It returns {state, reason, evidence}, as the pull request's does; #42 adds issue.decide beside it. The Controller, when built, picks by item.type. Why: the derivation reads only an issue's own facts. If one Kernel entry is wanted later, the smallest touch is a two-line dispatch at the top of decide.decide, decided with controller/. kernel/issue.py imports is_time, runs_on, in_progress and standing_labels from derive.py, and is_person from gate.py. It imports them and never edits them. Why: one calendar, and one reading of "what a person placed, only a person ends" (L-17). The pull-request lane treats those five names as a contract. kernel/AGENTS.md gains one sentence naming the module.
1. Facts.schemas/issue-facts.schema.json holds every field of §3.3, as "schema": "issue-facts/1" with item type issue. Two shapes differ from §3.3's wording, and the same pull request corrects §3.3:
Control labels are label_events, as on a pull request: label, action, by and at. Everything else is in label_names.
Each linked entry carries the pull request's number, class (as the port gives it for a pull request), opener, open, draft, merged_at, closed_at, empty, and its own label_events. The Kernel reads the Hold, hands-off and "admitted once" from those.
The complete flags are timeline, linked and runs. Each counter-example is wrong in one way: prose, no completeness, a label event without who, a linked pull request without its class, a time in another format, reopened_last_by in words. Why: events let the pull request's tested reading serve the Hold, hands-off and admission. factory:triage and factory:ready count from their latest add, and any later removal ends them: they let work go, so a removal stops it.
2. Config.config/defaults.json and its schema gain one block, issues: served_from, left_out_labels, left_out_types, and label_apps, which names the App whose factory:triage and factory:ready count. By default served_from is null and serves no issue. Each App is null, so only a person's label counts. Why: the Consumer entry has no fields for issues yet (§9.9). Null fails closed.
3. States and Lifecycle. The nine States of lifecycle/issue.json are codes in kernel/issue.py. held and not_served reuse the codes in terms.py. #41 adds no transition, so #59's check_lifecycle proof stands. A test asserts that every State the function returns is declared, and that every declared State has a code. lifecycle/AGENTS.md loses "which it does not yet".
Row 4's "Run ceiling is reached" is not derived here. In #42 the decision asks brakes.on_run, which is not edited and already gives held/run_ceiling. Why: the brakes count the ceiling and nothing else does (decision 0003).
4. Short and unreadable facts. These checks come before row 1. A complete flag that is not exactly true defers with facts_incomplete. If a control-label event, a linked entry, life_started_at, reopened_last_by, content_id or content_changed_at cannot be read, it defers with state null and a code of its own: label_unreadable, linked_unreadable, life_unreadable or content_unreadable. A closed issue gets no exception. Why: rows 1 and 2 both read linked and the timeline. Neither terminal State admits anything. When two things happen in the same second, the reading that stops work wins: a merge at the start of a life gives done, and a label added at an edit or a reopening does not count.
5. Tests and mutants.tests/test_issue.py keeps its builders inside it, not in tests/support.py. Written first:
the 33 cases of derive_issue_model.py, each boundary from both sides;
cases no row has yet: a Factory factory:ready whose Run record has expired; one whose Run judged other content; a label a person removed. The issue says 35 and the script has 33, so the pull request says which of these it counts as the missing two.
check_purity.run on a tree whose kernel/issue.py reads the clock, which fails and names that file. check_purity already walks all of kernel/, unchanged.
There is one mutant per row, one per clause of accepted, linked and counts, and one for the incompleteness guard. Each names tests.test_issue as its fifth part. They sit in a list ISSUE above MUTANTS, which becomes MUTANTS = ISSUE + [. Why: the pull-request lane appends at the end of the list, so the two lanes never touch the same lines.
6. Simulator. Left to #43, with the issue's twin of test_transitions (each change of State, and who made it). Test one suspicion first: acceptance lapsing is drawn human, but an outsider's edit causes it. Why:sim/world.py is protected, and those moves need a world.
7. Record.docs/decisions/0029-an-issues-state-is-derived-in-a-module-of-its-own.md records the module boundary, the five imports, the event shapes, the null defaults and the ceiling left to the brakes.
8. Follow-ups. Fill label_apps when #26 registers the Apps. Correct row 4's wording in §4.1. Once both lanes land, move the shared readers into one module.
Taken as working rulings (the Maintainer may overturn them in review):
Activity
don-petry commentedon Oct 17, 2026
Working rulings (overturnable in review)
Solution Architect, delegated. On main at 3d4109c (#145).
0. The lane holds. #41 edits nothing in
kernel/decide.py,kernel/derive.pyorkernel/terms.py. An issue's State comes from one new function,derive(facts, runs, config)inkernel/issue.py. It returns{state, reason, evidence}, as the pull request's does; #42 addsissue.decidebeside it. The Controller, when built, picks byitem.type. Why: the derivation reads only an issue's own facts. If one Kernel entry is wanted later, the smallest touch is a two-line dispatch at the top ofdecide.decide, decided withcontroller/.kernel/issue.pyimportsis_time,runs_on,in_progressandstanding_labelsfromderive.py, andis_personfromgate.py. It imports them and never edits them. Why: one calendar, and one reading of "what a person placed, only a person ends" (L-17). The pull-request lane treats those five names as a contract.kernel/AGENTS.mdgains one sentence naming the module.1. Facts.
schemas/issue-facts.schema.jsonholds every field of §3.3, as"schema": "issue-facts/1"with item typeissue. Two shapes differ from §3.3's wording, and the same pull request corrects §3.3:label_events, as on a pull request: label, action, by and at. Everything else is inlabel_names.linkedentry carries the pull request's number,class(as the port gives it for a pull request), opener, open, draft,merged_at,closed_at, empty, and its ownlabel_events. The Kernel reads the Hold, hands-off and "admitted once" from those.The
completeflags aretimeline,linkedandruns. Each counter-example is wrong in one way: prose, no completeness, a label event without who, a linked pull request without its class, a time in another format,reopened_last_byin words.Why: events let the pull request's tested reading serve the Hold, hands-off and admission.
factory:triageandfactory:readycount from their latest add, and any later removal ends them: they let work go, so a removal stops it.2. Config.
config/defaults.jsonand its schema gain one block,issues:served_from,left_out_labels,left_out_types, andlabel_apps, which names the App whosefactory:triageandfactory:readycount. By defaultserved_fromis null and serves no issue. Each App is null, so only a person's label counts. Why: the Consumer entry has no fields for issues yet (§9.9). Null fails closed.3. States and Lifecycle. The nine States of
lifecycle/issue.jsonare codes inkernel/issue.py.heldandnot_servedreuse the codes interms.py. #41 adds no transition, so #59'scheck_lifecycleproof stands. A test asserts that every State the function returns is declared, and that every declared State has a code.lifecycle/AGENTS.mdloses "which it does not yet".Row 4's "Run ceiling is reached" is not derived here. In #42 the decision asks
brakes.on_run, which is not edited and already givesheld/run_ceiling. Why: the brakes count the ceiling and nothing else does (decision 0003).4. Short and unreadable facts. These checks come before row 1. A
completeflag that is not exactly true defers withfacts_incomplete. If a control-label event, a linked entry,life_started_at,reopened_last_by,content_idorcontent_changed_atcannot be read, it defers with state null and a code of its own:label_unreadable,linked_unreadable,life_unreadableorcontent_unreadable. A closed issue gets no exception. Why: rows 1 and 2 both readlinkedand the timeline. Neither terminal State admits anything. When two things happen in the same second, the reading that stops work wins: a merge at the start of a life givesdone, and a label added at an edit or a reopening does not count.5. Tests and mutants.
tests/test_issue.pykeeps its builders inside it, not intests/support.py. Written first:derive_issue_model.py, each boundary from both sides;factory:readywhose Run record has expired; one whose Run judged other content; a label a person removed. The issue says 35 and the script has 33, so the pull request says which of these it counts as the missing two.check_purity.runon a tree whosekernel/issue.pyreads the clock, which fails and names that file.check_purityalready walks all ofkernel/, unchanged.There is one mutant per row, one per clause of accepted, linked and counts, and one for the incompleteness guard. Each names
tests.test_issueas its fifth part. They sit in a listISSUEaboveMUTANTS, which becomesMUTANTS = ISSUE + [. Why: the pull-request lane appends at the end of the list, so the two lanes never touch the same lines.6. Simulator. Left to #43, with the issue's twin of
test_transitions(each change of State, and who made it). Test one suspicion first: acceptance lapsing is drawnhuman, but an outsider's edit causes it. Why:sim/world.pyis protected, and those moves need a world.7. Record.
docs/decisions/0029-an-issues-state-is-derived-in-a-module-of-its-own.mdrecords the module boundary, the five imports, the event shapes, the null defaults and the ceiling left to the brakes.8. Follow-ups. Fill
label_appswhen #26 registers the Apps. Correct row 4's wording in §4.1. Once both lanes land, move the shared readers into one module.Taken as working rulings (the Maintainer may overturn them in review):
issuesblock inconfig/defaults.json, and the stale line inlifecycle/AGENTS.md. Both get every model Lens's review (Kernel: a change to the Factory's own rules or a Lens's inputs is reviewed by a model Lens #135).don-petry commentedon Oct 17, 2026
Decision record renumbered to 0028 (#146 needs no record, so the numbers close up). Merge order follows the numbers.