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
Schemas for the Role manifest, the pass record and the feed; and one envelope for all #28
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".
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)
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}: readruns 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
{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).
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:
outcomeadmit or act: factory. The Factory is about to do something.
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
constrole/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_actorlens and decision_required null. Each title is a short, harmless string.
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.
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.
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.
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.
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.
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.
Activity
don-petry commentedon Oct 18, 2026
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.pyandtests/support.py. Revised on 2026-10-18 for the Maintainer's two answers:applied.jsonandoutcome.jsonare 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 thereleasethat produced it. Taken literally, that reaches too far:coverage/1is an array, so it cannot carry fields.config/defaults.jsonis read only by the Release that contains it.On
maintoday:task,resultand the feed do not exist yet.decision/1andfinding/1dropped §3.4's and §3.7'sidandrelease;finding/1kept itsid.run-recordandcoveragecarry noschema.configandhookscarry noschemaeither.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.
ENVELOPEintools/check_examples.pymaps every kind inschemas/to its class.schema, its identity,releasetask,result,appliedandoutcome(identityrun);pass(identitypass);view-metaandview-items(identitypass, the pass that wrote them)schema; its identity and Release are its document'sfacts,issue-facts,decision,issue-decision,finding(which keeps itsid)$refeffect,disposition,coverage,run-name,run-record,view-itemschema; its identity is its path, its Release the commitconfig,hooks,lifecycle,authority,apps,rubric,acceptance,trial,role,feedschemais aconst<kind>/<major>, with<kind>the file's name.releaseis^factory/v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$, the same pattern as Schemas for Task, Result, Effect and Disposition #19's.runis^r_[0-9A-Z]{6,26}$, as in Schemas for Task, Result, Effect and Disposition #19, andpassis^p_[0-9A-Z]{6,26}$, minted by the Controller as it mintsrun.Why:
schemabecause 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:ENVELOPE, and every kind inENVELOPEhas a schema.schema, its identity andrelease. Itsreleaseis the pattern above, and its identity's pattern is anchored at both ends.schema, and has norelease.schema'sconstnames the file's own kind.schemaproperty.Two existing schemas fail this today and gain
schema:config.schema.jsonwithconfig/1, andhooks.schema.jsonwithhooks/1. So do their files,config/defaults.jsonandconfig/hooks.json. Three of those four paths are protected, so the hooks ask the Maintainer for each edit. Check before the edit thattools/hooks/and the Kernel's readers of the configuration ignore a key they do not name.CODES_ONLY(#19, ruling 5) gainspass,applied,outcome,view-meta,view-items,view-item,roleandfeed. Its one exemption, a property namedbody, gains a second: a string inside a property nameduntrusted, which onlyview-itemhas (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.schema,pass,releasenowcontext$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_untilandpasses_since(time or null), andunidentified_runs(GitHub run ids,^[0-9]+$, or null) once 0034's code is onmain. A key left out reads differently from null (model_deferred_untilreads a missing key asunreadable). The Controller therefore always passes every key, and the record keeps them allruns{"$ref": "run-record.schema.json"}: every run record the pass read, once for the pass, not once for each itemreads{read, date, age}:readrunsorfacts,datea time,agean integer at least 0. These are #161's ruling 4: GitHub'sdateandagefor each read, so that a stale read is counted (P-8)pull_requests{facts, decision, run}:factsrefsfacts.schema.json,decisionrefsdecision.schema.json, andrunis the id the Controller minted for an admitted Run or an approval, or nullissuesissue-factsandissue-decisionThe configuration is not copied: it is the data at
release. Two lists, not one, because withoutoneOfonly two lists can say that a pull request's facts go with a pull request's Decision. Whyrunis 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/1andissue-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 anenum, aconstor an anchoredpattern, and "text" means any string.itemstate,reason,attention,evidencestateandattentionare the union ofdecision/1's andissue-decision/1's enumsnext_actormaintainer,author,lens,factory,platformornobodydecision_required{question, choices, evidence}:question^[a-z_]+$;choicesat least one, each{"$ref": "feed.schema.json#/$defs/choice"};evidencethe Decision'scontent_id^sha256:[0-9a-f]{4,64}$, or nullage_hoursattempts{intent, count}. A list, not an object keyed by Intent: the validator cannot constrain a key, so a key could hold anythingrunscost_usdsourcesurl^https://github\.com/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+/(pull|issues)/[1-9][0-9]*$untrusted{title}, required.titleis a string, the item's title as GitHub holds it. This is the only text in the feedThe title.
facts/1andissue-facts/1have no field for a title, and do not gain one.untrusted, so anything that reads the feed sees it marked (§8.4).CODES_ONLYlets text through only underuntrustedor in abody.examples/invalid/facts.carries-prose.json, already there.examples/invalid/facts.carries-the-title.jsonandexamples/invalid/issue-facts.carries-the-title.json. Each is wrong in one way: an unexpectedtitle.tools/check_examples.pyholds 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
untrustedwhole (§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 isschemas/feed.schema.json, inLIVE. 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_actoris read in order, and the first rule that matches wins:outcomeadmitoract:factory. The Factory is about to do something.attentionneeds_you:maintainer.working:factory.failed:author.none:nobody.attentionreview,readyorstale, bystate:verifyingoradjudicating:lens;queued:platform, the merge queue;factory.In
feed.jsonthis is two lists:by_attention, rows of{attention, actor}whereactormay beby_state; andby_state, rows of{state, actor}, with adefault. Rule 1 is the one row{outcome: [admit, act], actor: factory}. Every attention class appears exactly once.decision_requiredis null unlessattentionisneeds_you. When it is, the table's row for the Decision'sreasongivesquestionandchoices, andevidenceis copied from the Decision. Each row is{reason, question, choices}. Achoiceis something a person does in GitHub (§0), one of:end_hold;admit, which addsfactory: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_roundsandact_ceiling:allowance_spent, withrenew_allowanceorclose.human_hold:held_by_you, withend_holdorclose.outside_not_admitted:admit_outside_work, withadmitorclose.too_large:too_large, withsplit_the_changeorclose.contract_not_approvedandcontract_changed:contract_needs_approval, withapprove_contractorclose.*_unreadable:fact_unreadable, withfix_the_source.pausedandpause_unreadable:paused, withend_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_REASONSinkernel/decide.py;NEEDS_YOU_REASONSinkernel/issue.py;needs_yououtside those sets:pausedandpause_unreadable, from the brakes, andunmerged_closes, fromkernel/issue.py. These three are named in the check asALSO_NEEDS_YOU, with the tests that show it.The check, in
tools/check_examples.py, which already importskernel.coverage, reports:needs_you;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.jsonandoutcome.json(§3.10), this story's fourth criterion. Both are documents, identityrun, uploaded by the Run that the Task names. No text appears in either: bodies stay inresult.json, which they point at byrun.schemas/applied.schema.json,applied/1, uploaded byapply:schema,run,releaserunandreleaseequal the Task'seffectsapplymet, in the order applied:{id, type, state, key, reason}.idis the Result's Effect id,^e[0-9]{1,3}$.typerefseffect.schema.json#/$defs/type.stateisapplied,already_applied(found by its recovery key, §3.6),refusedorfailed.keyis what was read back: the thread, review, check run or comment node id,^[A-Za-z0-9_=:-]+$, or null where nothing was written.reasonis^[a-z_]+$or null: why it was refused or failedfindings{"$ref": "finding.schema.json"}: thefinding/1of each Findingapplyposted, with its surface's key as read back (#19, ruling 3)schemas/outcome.schema.json,outcome/1, the few bytes a final step writes:schema,run,releaseoutcomeitem,capacity,environmentorsuperseded; null for a Run that succeeded. These are the run record'soutcomecodes (§3.10,run-record.schema.json)reason^[a-z_]+$, or nullA killed Run writes neither file, and §3.10 already says how that is read. Why
findingslives inapplied: a Role's Finding has its surface only onceapplyhas 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.schemaconstrole/1name^[a-z][a-z-]*[a-z]$kindauthor,reviewer,owner,shadoworadvisorowns,shadowsapp^combsmith-sf-[a-z-]+$; it appears at promotionlensscope(pathsandexclude, globs^[^\s]+$;always, a boolean) andraises(categories)intents{name, tier, permitted_effects, budget_class, evals}:tierisstandardordeep; each permitted effect refseffect.schema.json#/$defs/type;budget_classisgateorshadow;evalsis^evals/[a-z][a-z-]*$and required (FR-55: a gate-deciding Intent may not serve without one)tools/check_authority.pyholds the manifest's category enum to finding/1's, as it holds the facts' and #19's. No live manifest exists untilroles/. So these wait onroles/:shadow;ownsagrees withconfig/authority.json;nameis its file's;roles/*.jsonjoinLIVE.7. Examples and counter-examples. Each counter-example is wrong in exactly one way.
pass.json: one pull request admitted with itsrun, and one issue with nothing to do, built by calling the Kernel (ruling 8).view-meta.json.view-items.json: one itemneeds_you(attempts_exhausted) with itsdecision_required, and one inreviewwithnext_actorlensanddecision_requirednull. 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 twofinding/1.applied.refused.json: an Effect refused, withkeynull.outcome.json:item,result_invalid.role.json: §3.11'sdevops-leadin shadow.role.owner.json:pr-reviewas 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:titlebesideuntrusted, 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, abodyon an Effect entry;an-effect-read-back-in-words,stateposted 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.
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 norelease; a document whose identity has no anchored pattern; a record with noschema; aconstnaming another kind; a part with aschemaproperty; a record or a data schema with arelease. Onmain's schemas, withconfigandhooksgivenschema, it is clean.test_text_is_let_through_only_untrusted_or_in_a_body. A string underuntrustedpassesCODES_ONLY; the same string astitlebeside it is caught.test_the_example_pass_record_replays. For every entry ofexamples/pass.json,kernel.decide.decide(orkernel.issue.decidefor an issue) on the recorded facts,runs,contextandnow, 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 oftests/support.py'scontext(), which every Kernel test is given.test_the_feed_shows_only_what_a_decision_can_say.view-item'sstateandattentionenums are the union of the two Decisions'.test_every_reason_that_needs_a_person_says_what_is_asked. The table check is clean onconfig/feed.json. It catches each of these: a row taken out; a row forfindings_open; a reason given twice; an attention class with no rule.test_the_example_feed_follows_the_table. Each item inexamples/view-items.jsonhas thenext_actoranddecision_requiredthe table gives its Decision's codes. The test's own reader of the table is ten lines. The Controller's waits oncontroller/.paused,pause_unreadableandunmerged_closesis shown to giveneeds_youby an existing test intests/test_decide.pyortests/test_issue.py. The builder names those tests besideALSO_NEEDS_YOU, or adds one where none asserts the attention.tools/check_examples.py:untrustedexemption matches any property. TheCODES_ONLYtest 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:
pass);untrustedand never in the facts;next_actoranddecision_required, read from the Decision's codes throughconfig/feed.json.Its Boundaries:
carries-the-titlecounter-examples andfacts.carries-prose;adapters/: the facts port returns the title beside the facts, never in them, from the same query;controller/:pass/1with every key of the context, every run record and every read'sdateandage;passandrun, and writesrunbeside the Decision that admitted it;view/meta.jsonandview/items.jsonare written by one pass, carrying its id;next_actoranddecision_requiredare read fromconfig/feed.jsonand from nothing else;harness/:applyuploadsapplied/1with every Effect it met and every Finding it posted;outcome/1;adapters/: the records port readsoutcome.jsononly for a Run that did not succeed;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:
idandrelease, and the pass record's paragraph gains its fields.release.applied.jsonandoutcome.jsonname their schemas.view-itemhas noschemaof its own;untrusted;next_actoranddecision_requiredare read fromconfig/feed.json;questionandchoicesare enumerated codes" now names that table.10. Out of scope.
view/runs,findings,scorecard,comparison,capacity,healthandconsumers: slice 4.run-record'srunSchemas for Task, Result, Effect and Disposition #19's pattern. The Kernel's tests user1, and the port's output isadapters/'s.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.pyand §3.7. Start it once #19 is onmain. 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.jsonandoutcome.jsonare this story's fourth criterion (ruling 5).next_actoranddecision_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,outcomeand the Role manifest in another.don-petry commentedon Oct 18, 2026
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.
don-petry commentedon Oct 19, 2026
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.
don-petry commentedon Oct 19, 2026
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.
don-petry commentedon Oct 19, 2026
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.