Skip to content

Kernel: derive an issue's State from its facts #41

Closed

Description

@don-petry

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.

Activity

  1. don-petry commented on Oct 17, 2026

    @don-petry
    ContributorAuthor

    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.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):

    • The two protected edits stand: the issues block in config/defaults.json, and the stale line in lifecycle/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).
    • No issue is served until the Maintainer turns issues on: the defaults are null. Which issues, labels and types are served is decided then.
    • The cost of reading each linked pull request's timeline every pass is left to trial P-8, which measures it.
  2. don-petry commented on Oct 17, 2026

    @don-petry
    ContributorAuthor

    Decision record renumbered to 0028 (#146 needs no record, so the numbers close up). Merge order follows the numbers.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions