Skip to content

Schemas for the Role manifest, the pass record and the feed; and one envelope for all #28

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 first slice also uses a Role manifest, a pass record and a feed of two files. And §3.1 asks every message for a schema name, an identifier and the Release that produced it, which none of the nine schemas on main carries in full.

Done when

  • The Role manifest and the pass record each have a schema, an example and a counter-example.
  • The feed's two files, meta and items, have the same.
  • Every schema in the repo carries the envelope of §3.1, and a check fails for one that does not.

Needs. The story "Schemas for Task, Result, Effect and Disposition".

Design. §3.1; §3.10, §3.11; §8.4; §9.1, slice 1.

Activity

  1. don-petry commented on Oct 18, 2026

    @don-petry
    ContributorAuthor

    Working rulings (overturnable in review)

    Solution Architect, delegated. On main at c094a8b (#170). Builds on the working rulings for #19 (record 0035), which must merge first, and on #161's (record 0034). Read from design §3.1, §3.3, §3.4, §3.7, §3.10, §3.11, §7.2, §8.4 and §9.1, and from kernel/decide.py, kernel/issue.py and tests/support.py. Revised on 2026-10-18 for the Maintainer's two answers: applied.json and outcome.json are this story's fourth criterion (ruling 5), and slice 1's feed carries the title, the next actor and the decision required (ruling 4).

    What this story decides. §3.1 says every message carries schema, an identifier and the release that produced it. Taken literally, that reaches too far:

    • A Decision would need an id and a Release. The Kernel can mint neither: it reads no clock and keeps nothing.
    • coverage/1 is an array, so it cannot carry fields.
    • An Effect and a Disposition travel only inside a Result, which already says which Run and which Release.
    • config/defaults.json is read only by the Release that contains it.

    On main today:

    • task, result and the feed do not exist yet.
    • decision/1 and finding/1 dropped §3.4's and §3.7's id and release; finding/1 kept its id.
    • run-record and coverage carry no schema.
    • config and hooks carry no schema either.

    So the rule is: the envelope is carried by what crosses a boundary whole, once, and everything inside it is versioned by it.

    1. Four classes, and each schema is in exactly one. ENVELOPE in tools/check_examples.py maps every kind in schemas/ to its class.

    Class Carries Kinds
    document: crosses a boundary whole, written by a Release at run time schema, its identity, release task, result, applied and outcome (identity run); pass (identity pass); view-meta and view-items (identity pass, the pass that wrote them)
    record: inside a document, and also given alone to a reader: the Kernel, a replay, a join schema; its identity and Release are its document's facts, issue-facts, decision, issue-decision, finding (which keeps its id)
    part: only ever inside another, by $ref nothing effect, disposition, coverage, run-name, run-record, view-item
    data: a file a person edits, read by the Release that holds it or, in a Consumer repo, by whichever serves it schema; its identity is its path, its Release the commit config, hooks, lifecycle, authority, apps, rubric, acceptance, trial, role, feed

    Why:

    • One fact, one place (0009). A Decision's Release is its pass's, and a Disposition's Run is its Result's. A copy in every record is a copy that can disagree.
    • Nothing has to give the Kernel an id or a Release.
    • A data file keeps schema because acceptance contracts and Consumer entries are read by whichever Release serves them, and one rule is easier to hold than two.

    2. The check. In tools/check_examples.py, beside the check that a schema accepts no unknown fields:

    • Every schema is in ENVELOPE, and every kind in ENVELOPE has a schema.
    • A document requires schema, its identity and release. Its release is the pattern above, and its identity's pattern is anchored at both ends.
    • A record or a data schema requires schema, and has no release.
    • schema's const names the file's own kind.
    • A part has no schema property.

    Two existing schemas fail this today and gain schema: config.schema.json with config/1, and hooks.schema.json with hooks/1. So do their files, config/defaults.json and config/hooks.json. Three of those four paths are protected, so the hooks ask the Maintainer for each edit. Check before the edit that tools/hooks/ and the Kernel's readers of the configuration ignore a key they do not name.

    CODES_ONLY (#19, ruling 5) gains pass, applied, outcome, view-meta, view-items, view-item, role and feed. Its one exemption, a property named body, gains a second: a string inside a property named untrusted, which only view-item has (ruling 4). Existing schemas are not swept here.

    3. The pass record, schemas/pass.schema.json, pass/1, a document. It is what replay needs and nothing else. decide(facts, runs, context, config, now) is replayed from these fields and the Release's own data files.

    Field Holds
    schema, pass, release The envelope
    now The time the pass gave the Kernel, in the facts' time format
    context $defs/context. It has exactly the keys the Kernel is given, all required: paused (boolean or null), active_runs (integer at least 0, or null), model_deferred_until and passes_since (time or null), and unidentified_runs (GitHub run ids, ^[0-9]+$, or null) once 0034's code is on main. A key left out reads differently from null (model_deferred_until reads a missing key as unreadable). The Controller therefore always passes every key, and the record keeps them all
    runs items {"$ref": "run-record.schema.json"}: every run record the pass read, once for the pass, not once for each item
    reads {read, date, age}: read runs or facts, date a time, age an integer at least 0. These are #161's ruling 4: GitHub's date and age for each read, so that a stale read is counted (P-8)
    pull_requests {facts, decision, run}: facts refs facts.schema.json, decision refs decision.schema.json, and run is the id the Controller minted for an admitted Run or an approval, or null
    issues the same, with issue-facts and issue-decision

    The configuration is not copied: it is the data at release. Two lists, not one, because without oneOf only two lists can say that a pull request's facts go with a pull request's Decision. Why run is here: the Harness finds the Decision that admitted a Run by the Run's id (0034: the dispatch carries the run-name, not the pass), and the pass record is the only place the Decision and the minted id meet.

    There is no title anywhere in the pass record: it carries facts/1 and issue-facts/1, which have no field for one (ruling 4). So replay, like the Kernel, never sees it.

    4. The feed, slice 1's two files (§9.1).

    • view-meta/1, a document: schema, pass, release, written_at (time), reaches_back_to (time, the oldest run record folded in).
    • view-items/1, a document: schema, pass, release, items, each item {"$ref": "view-item.schema.json"}. Both files carry the pass, so the page can tell a matched pair from two files written by different passes.
    • view-item, a part. In this table "code" means held by an enum, a const or an anchored pattern, and "text" means any string.
    Field Holds Code or text
    item As the Decision's Code
    state, reason, attention, evidence Copied from the item's Decision. state and attention are the union of decision/1's and issue-decision/1's enums Code
    next_actor maintainer, author, lens, factory, platform or nobody Code
    decision_required Null, or {question, choices, evidence}: question ^[a-z_]+$; choices at least one, each {"$ref": "feed.schema.json#/$defs/choice"}; evidence the Decision's Code
    content_id ^sha256:[0-9a-f]{4,64}$, or null Code
    age_hours A number at least 0 Number
    attempts A list of {intent, count}. A list, not an object keyed by Intent: the validator cannot constrain a key, so a key could hold anything Code
    runs An integer at least 0 Number
    cost_usd A number at least 0, or null where no Run's cost was read Number
    sources Run ids Code
    url ^https://github\.com/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+/(pull|issues)/[1-9][0-9]*$ Code
    untrusted {title}, required. title is a string, the item's title as GitHub holds it. This is the only text in the feed Text

    The title.

    • Where it comes from. The facts port's one query for the item already returns the title, so reading it costs no extra request. The port hands the title to the view port beside the facts, never in them. facts/1 and issue-facts/1 have no field for a title, and do not gain one.
    • How it is held. It sits under untrusted, so anything that reads the feed sees it marked (§8.4). CODES_ONLY lets text through only under untrusted or in a body.
    • Why it never reaches a decision. The Kernel is given facts, run records, context, configuration and the time, and nothing else. None of these can hold a title. The pass record, which replay reads, carries only the facts. The feed is written and never read by anything that decides (0002).
    • Which checks enforce that:
      • examples/invalid/facts.carries-prose.json, already there.
      • Two new counter-examples: examples/invalid/facts.carries-the-title.json and examples/invalid/issue-facts.carries-the-title.json. Each is wrong in one way: an unexpected title. tools/check_examples.py holds all three.
      • tests/test_decide.py::test_admission_is_given_facts_and_never_an_event, which holds what the Kernel is given (0002).

    So the rule "no text from outside reaches a decision" still holds, and is now held for the title by name. The page shows the title as text. A reader of the feed that feeds a model, such as a board inside a Claude session, drops untrusted whole (§8.4).

    Who acts next, and what is asked. Both come from the Decision's codes through a fixed table, and the Kernel gives nothing new. The table is data: config/feed.json, feed/1, whose schema is schemas/feed.schema.json, in LIVE. Why data: controller/, which will apply the table, is a reserved path. Creating it now would trip every Boundary row waiting on it (0013). A table is also what the page needs, to render the sentence a person reads from each code.

    next_actor is read in order, and the first rule that matches wins:

    1. outcome admit or act: factory. The Factory is about to do something.
    2. attention needs_you: maintainer. working: factory. failed: author. none: nobody.
    3. attention review, ready or stale, by state:
      • verifying or adjudicating: lens;
      • queued: platform, the merge queue;
      • any other: factory.

    In feed.json this is two lists: by_attention, rows of {attention, actor} where actor may be by_state; and by_state, rows of {state, actor}, with a default. Rule 1 is the one row {outcome: [admit, act], actor: factory}. Every attention class appears exactly once.

    decision_required is null unless attention is needs_you. When it is, the table's row for the Decision's reason gives question and choices, and evidence is copied from the Decision. Each row is {reason, question, choices}. A choice is something a person does in GitHub (§0), one of:

    • end_hold;
    • admit, which adds factory:admit;
    • renew_allowance, any action by a person with write access;
    • approve;
    • approve_contract;
    • resolve_thread;
    • fix_the_source, which mends the configuration, a check or an App;
    • end_pause;
    • split_the_change;
    • close.

    Examples:

    • attempts_exhausted, run_ceiling, max_rounds and act_ceiling: allowance_spent, with renew_allowance or close.
    • human_hold: held_by_you, with end_hold or close.
    • outside_not_admitted: admit_outside_work, with admit or close.
    • too_large: too_large, with split_the_change or close.
    • contract_not_approved and contract_changed: contract_needs_approval, with approve_contract or close.
    • Each *_unreadable: fact_unreadable, with fix_the_source.
    • paused and pause_unreadable: paused, with end_pause.

    The builder writes a row for every reason. The check below makes sure none is missing.

    The reasons that need a person are:

    • NEEDS_YOU_REASONS in kernel/decide.py;
    • NEEDS_YOU_REASONS in kernel/issue.py;
    • three more the Kernel gives needs_you outside those sets: paused and pause_unreadable, from the brakes, and unmerged_closes, from kernel/issue.py. These three are named in the check as ALSO_NEEDS_YOU, with the tests that show it.

    The check, in tools/check_examples.py, which already imports kernel.coverage, reports:

    • a reason that needs a person and has no row;
    • a row for a reason the Kernel never gives needs_you;
    • a reason with two rows;
    • an attention class with no rule, or with two.

    Why a table and not Kernel output: each field is a function of codes the Decision already holds. Kernel output would be a second copy of the same answer, and kernel/ is where every new field costs a boundary test and a mutant.

    5. applied.json and outcome.json (§3.10), this story's fourth criterion. Both are documents, identity run, uploaded by the Run that the Task names. No text appears in either: bodies stay in result.json, which they point at by run.

    schemas/applied.schema.json, applied/1, uploaded by apply:

    Field Holds Code or text
    schema, run, release The envelope; run and release equal the Task's Code
    effects One entry for each Effect apply met, in the order applied: {id, type, state, key, reason}. id is the Result's Effect id, ^e[0-9]{1,3}$. type refs effect.schema.json#/$defs/type. state is applied, already_applied (found by its recovery key, §3.6), refused or failed. key is what was read back: the thread, review, check run or comment node id, ^[A-Za-z0-9_=:-]+$, or null where nothing was written. reason is ^[a-z_]+$ or null: why it was refused or failed Code
    findings items {"$ref": "finding.schema.json"}: the finding/1 of each Finding apply posted, with its surface's key as read back (#19, ruling 3) Record

    schemas/outcome.schema.json, outcome/1, the few bytes a final step writes:

    Field Holds Code or text
    schema, run, release The envelope Code
    outcome item, capacity, environment or superseded; null for a Run that succeeded. These are the run record's outcome codes (§3.10, run-record.schema.json) Code
    reason ^[a-z_]+$, or null Code

    A killed Run writes neither file, and §3.10 already says how that is read. Why findings lives in applied: a Role's Finding has its surface only once apply has posted it (#19). The facts port joins a thread's category and severity from here.

    6. The Role manifest, schemas/role.schema.json, role/1, data. It follows §3.11, held as JSON.

    Field Holds
    schema const role/1
    name ^[a-z][a-z-]*[a-z]$
    kind author, reviewer, owner, shadow or advisor
    owns, shadows optional; categories, finding/1's enum copied
    app optional, ^combsmith-sf-[a-z-]+$; it appears at promotion
    lens scope (paths and exclude, globs ^[^\s]+$; always, a boolean) and raises (categories)
    intents at least one; each {name, tier, permitted_effects, budget_class, evals}: tier is standard or deep; each permitted effect refs effect.schema.json#/$defs/type; budget_class is gate or shadow; evals is ^evals/[a-z][a-z-]*$ and required (FR-55: a gate-deciding Intent may not serve without one)

    tools/check_authority.py holds the manifest's category enum to finding/1's, as it holds the facts' and #19's. No live manifest exists until roles/. So these wait on roles/:

    • that a shadow's Intents permit no Effect and spend from shadow;
    • that an owner's owns agrees with config/authority.json;
    • that a Role's name is its file's;
    • that roles/*.json join LIVE.

    7. Examples and counter-examples. Each counter-example is wrong in exactly one way.

    • Examples:
      • pass.json: one pull request admitted with its run, and one issue with nothing to do, built by calling the Kernel (ruling 8).
      • view-meta.json.
      • view-items.json: one item needs_you (attempts_exhausted) with its decision_required, and one in review with next_actor lens and decision_required null. Each title is a short, harmless string.
      • applied.json: Schemas for Task, Result, Effect and Disposition #19's review Run, two Effects applied and their two finding/1.
      • applied.refused.json: an Effect refused, with key null.
      • outcome.json: item, result_invalid.
      • role.json: §3.11's devops-lead in shadow.
      • role.owner.json: pr-review as slice 1 runs it.
    • invalid/facts.carries-the-title.json; invalid/issue-facts.carries-the-title.json.
    • invalid/pass.: no-release; a-decision-without-its-facts; a-context-without-the-pause; a-run-id-in-another-form.
    • invalid/view-meta.: no-pass; time-in-another-format.
    • invalid/view-items.:
      • a-title-not-marked-untrusted: title beside untrusted, not inside it;
      • sentence-as-reason;
      • a-choice-no-person-can-make: approve_for_me;
      • a-next-actor-in-words;
      • no-release.
    • invalid/applied.: carries-the-comment-body, a body on an Effect entry; an-effect-read-back-in-words, state posted fine; no-run.
    • invalid/outcome.: a-fault-the-design-does-not-know, model; sentence-as-reason; no-release.
    • invalid/feed.: a-choice-no-person-can-make; a-question-in-words.
    • invalid/role.: a-kind-the-design-does-not-know; a-permitted-effect-a-result-cannot-request, enqueue_merge; an-intent-with-no-eval-set; a-tier-the-engine-does-not-know.

    The envelope's and the table's own failures are held by tests, not files. A counter-example can only break a schema.

    8. Tests and mutants, written first.

    • In tests/test_tools.py:
      • test_a_schema_without_its_envelope_is_caught. Each of these is caught: a schema in no class; a class naming a kind with no schema; a document with no release; a document whose identity has no anchored pattern; a record with no schema; a const naming another kind; a part with a schema property; a record or a data schema with a release. On main's schemas, with config and hooks given schema, it is clean.
      • test_text_is_let_through_only_untrusted_or_in_a_body. A string under untrusted passes CODES_ONLY; the same string as title beside it is caught.
      • test_the_example_pass_record_replays. For every entry of examples/pass.json, kernel.decide.decide (or kernel.issue.decide for an issue) on the recorded facts, runs, context and now, with the repo's configuration, equals the recorded Decision.
      • test_the_pass_record_holds_the_kernels_whole_context. $defs/context's properties are exactly the keys of tests/support.py's context(), which every Kernel test is given.
      • test_the_feed_shows_only_what_a_decision_can_say. view-item's state and attention enums are the union of the two Decisions'.
      • test_every_reason_that_needs_a_person_says_what_is_asked. The table check is clean on config/feed.json. It catches each of these: a row taken out; a row for findings_open; a reason given twice; an attention class with no rule.
      • test_the_example_feed_follows_the_table. Each item in examples/view-items.json has the next_actor and decision_required the table gives its Decision's codes. The test's own reader of the table is ten lines. The Controller's waits on controller/.
    • Each of paused, pause_unreadable and unmerged_closes is shown to give needs_you by an existing test in tests/test_decide.py or tests/test_issue.py. The builder names those tests beside ALSO_NEEDS_YOU, or adds one where none asserts the attention.
    • Mutants, appended, each with text that appears once in tools/check_examples.py:
      • "a document without its release passes". The envelope test kills it.
      • "a schema in no class passes". The same test kills it.
      • "a part may carry a schema". The same test kills it.
      • "text is let through under any name": the untrusted exemption matches any property. The CODES_ONLY test kills it.
      • "a reason that needs a person may have no question": the missing-row report is skipped. The table test kills it.

    9. Record and design. This reads §3.1 differently from its words, so it takes a new record, 0036, "One envelope, on what crosses a boundary whole". It merges after 0035 and 0034. Its Decision is:

    • rulings 1 and 2;
    • the pass record's and the feed's identity (pass);
    • the title, held under untrusted and never in the facts;
    • next_actor and decision_required, read from the Decision's codes through config/feed.json.

    Its Boundaries:

    • the tests and the counter-examples above;
    • "the facts have no field for a title", naming the two carries-the-title counter-examples and facts.carries-prose;
    • waiting on adapters/: the facts port returns the title beside the facts, never in them, from the same query;
    • waiting on controller/:
      • every pass uploads a pass/1 with every key of the context, every run record and every read's date and age;
      • the Controller mints pass and run, and writes run beside the Decision that admitted it;
      • view/meta.json and view/items.json are written by one pass, carrying its id;
      • next_actor and decision_required are read from config/feed.json and from nothing else;
    • waiting on harness/:
      • apply uploads applied/1 with every Effect it met and every Finding it posted;
      • a final step writes outcome/1;
    • waiting on adapters/: the records port reads outcome.json only for a Run that did not succeed;
    • waiting on roles/: ruling 6's four rows.

    0002's row "Every pass records the facts it read and the Decisions it made, so that any of them can be replayed" keeps its Check cell, controller/. The replay test proves that the record would be enough, not that a pass writes it.

    Design edits, in the same change:

    • §3.1: "Every message carries …" becomes the four classes.
    • §3.4: the Decision example loses id and release, and the pass record's paragraph gains its fields.
    • §3.7: the Finding example loses release.
    • §3.10: applied.json and outcome.json name their schemas.
    • §8.4:
      • view-item has no schema of its own;
      • the title moves under untrusted;
      • next_actor and decision_required are read from config/feed.json;
      • "question and choices are enumerated codes" now names that table.

    10. Out of scope.

    • view/runs, findings, scorecard, comparison, capacity, health and consumers: slice 4.
    • The page, and how it renders a code as a sentence: slice 4.
    • Moving the existing records onto references.
    • Giving run-record's run Schemas for Task, Result, Effect and Disposition #19's pattern. The Kernel's tests use r1, and the port's output is adapters/'s.
    • The run list's cache headers beyond recording them (0034).
    • Partial usage from a final step (P-18).

    Parallel with #19? No. See #19's rulings. This story classifies #19's five schemas and refs two of them. It uses #19's file references. It edits the same tools/check_examples.py, tests/test_tools.py, tools/mutants.py and §3.7. Start it once #19 is on main. Its context table also waits on 0034's code (unidentified_runs).

    Needs the Maintainer? None open. Both points were answered on 2026-10-18:

    • applied.json and outcome.json are this story's fourth criterion (ruling 5).
    • Slice 1's feed carries the title, next_actor and decision_required (ruling 4). This overturned the recommendation to leave them out.

    With four criteria this story is at the limit for one PR. If review finds it too large, the split is: the envelope and the pass record in one PR, and the feed, applied, outcome and the Role manifest in another.

  2. don-petry commented on Oct 18, 2026

    @don-petry
    ContributorAuthor

    Decision number, updated: this ruling's record is 0037, not 0036. #165 (PR #176) needs a record of its own and takes 0034, the next free number, because it merges sooner. Records merge in number order: 0032 #175, 0033 P-13, 0034 #165, 0035 #161, 0036 #19, 0037 #28. #26 takes a number when it is built.

  3. don-petry commented on Oct 19, 2026

    @don-petry
    ContributorAuthor

    Decision number, updated: this story's record is 0038 (it was 0037). #26's P-39 reach trial passed and is ready to build now, so it takes 0036, ahead of the stories still being ruled or built. Order: 0034 #165 (PR #176), 0035 #161, 0036 #26, 0037 #19, 0038 #28, 0039 #174 PR B.

  4. don-petry commented on Oct 19, 2026

    @don-petry
    ContributorAuthor

    Decision numbers change rule (Maintainer, 2026-10-19). A decision record takes its number when its pull request is clean and green and about to be enqueued, as the next free number. It is no longer fixed when the rulings are posted. The number posted on this issue is provisional. The builder renames the record at enqueue if an earlier PR took that number. CI's gapless decision check still holds the order. Boundary rows in other records name tests, never numbers, so nothing else moves. This ends the renumbering, and a story with a record no longer waits behind an unrelated story that holds a lower number.

  5. don-petry commented on Oct 19, 2026

    @don-petry
    ContributorAuthor

    Decision numbers are taken at enqueue (the Maintainer, 2026-10-19, on the plan review's recommendation). A record takes the next free number when its PR is clean and green and about to be enqueued, not when its ruling is posted. Rulings say "the next free number at enqueue". Boundary rows in other records name tests, never numbers, so nothing else moves. The order posted on 2026-10-19 (0035 #161, 0036 #26, 0037 #19, 0038 #28, 0039 #174 PR B) is the expected order, not a reservation: #161 keeps 0035 as the next to enqueue, and each of the others takes the next free number in the order it is actually enqueued. A rows-only PR still merges any time.

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