Skip to content

Decision Points

Some questions come up again and again, such as which builder tier builds a work item or whether a release may skip the human gate. A decision point declares one such question as data, with the inputs it reads and the deciders that answer it, and every answer it gets is a record in the workspace (ws-058).

The questions become part of the workspace’s specification, reviewable like any file. Every answer can be traced to its inputs. When a model gave it, the record also holds the model’s confidence and threshold. A model never settles anything alone. Its answer is a proposal until a person confirms it.

Points live in a JSON file that follows decision-points.schema.json, which @intentius/chant exports as @intentius/chant/workspace/decision-points.schema.json. The reference workspace keeps its points in decisions/points.json, and its slice-tier point reads as follows.

{
"points": {
"slice-tier": {
"title": "Which builder tier builds this work item",
"question": {
"type": "choice",
"instructions": "Pick the smallest builder tier that can build this work item.",
"criteria": {
"small": "A haiku-class builder. The work item fits the small limits.",
"medium": "A mid-size builder. The work item fits the medium limits.",
"large": "The largest builder. The work item is bigger than the medium limits."
}
},
"inputs": {
"work-item.criteria": "acceptance criteria in the work item",
"work-item.fits_small": "whether it is within the small tier's limits",
"work-item.fits_medium": "whether it is within the medium tier's limits"
},
"deciders": [
{
"kind": "table",
"rows": [
{ "when": { "work-item.fits_small": true }, "answer": "small" },
{ "when": { "work-item.fits_medium": true }, "answer": "medium" }
]
},
{ "kind": "model", "backend": "systemone", "model": "bosun-v3.1-1.7b", "threshold": 0.8 },
{ "kind": "quorum", "count": 1 }
]
}
}
}

The question has one of the three types of the POST /v1/systemone wire format (#2491).

TypeCriteria
noultrue and false, each with a description.
choice2 to 255 options, each with a description.
score2 to 10 ordered levels.

Each input is named for the output of the read contract it comes from, optionally followed by dotted field names. work-item.title is the title of a work item, and finding alone is a whole finding. A work item read by id also has its size, which chant measures for the slice-tier point (see Sizing a work item).

OutputWhere a reader finds it
record, decision, work-itema record in chant workspace records --json
finding, region, commitchant workspace graph --intent --json
memberchant workspace ls --json
gate, release, environmentchant workspace status --json
componentchant workspace graph --composites
asknowhere: the asker’s own id for an ad-hoc ask, and who asked (#3403)

An input naming anything else is refused. The caller supplies the values, and a table row can test only the declared names.

The deciders are asked in order.

  • A table answers with the first row whose when holds. when names inputs and a value each must equal, or one comparison (eq, ne, lt, lte, gt, gte or in). {} matches everything.
  • A model names a backend, a pinned model id and a threshold. An alias such as jev-latest is refused, since it moves when a new release ships. unreachable says what happens when the backend can’t answer. With escalate, the default, the next decider is asked. With fail, the ask fails and writes nothing.
  • A quorum of people is always last, with a count and optionally the trust policy roles whose holders count. A chain that does not end in one is refused.

Some questions are made up when they are asked. An agent asking the people working with it which way to go writes its own question and options at runtime. A point declared "adhoc": true allows these (#3403). Its question gives a type and instructions, what the asks are for, and no criteria. Each ask brings the question’s text and its candidates with points ask --candidates, and the answer record keeps them as asked, so people’s answer is checked against the options the question offered. Since no table row or model can know candidates that arrive with the ask, an ad-hoc point’s chain is one quorum:

"agent-question": {
"title": "A question an agent asked the people working with it",
"adhoc": true,
"question": { "type": "choice", "instructions": "An agent asked this question, with options of its own, and acts on the one people pick." },
"inputs": { "ask.id": "the asker's own id for this ask", "ask.by": "who asked" },
"deciders": [{ "kind": "quorum", "count": 1 }]
}

The question and candidates are part of the inputs hash, so an input that names the ask, such as the asker’s request id, keeps two asks of the same question apart.

The answers are records of a kind with an answers block naming the points file. The reference workspace’s is answers/answer.kind.mjs, named in the top-level records of its chant.workspace.json:

export const recordKind = {
name: "answer",
location: { dir: ".", match: "^[a-z][a-z0-9-]*-[0-9a-f]{12}\\.md$" },
format: "markdown-front-matter",
schema: { id: "urn:intentius:chant:point-answer:1", path: "answer.schema.json" },
idField: "id",
stateField: "state",
states: ["escalated", "proposed", "answered"],
closedStates: ["answered"],
constrains: { field: "constrains" },
source: { field: "source" },
answers: { points: "../decisions/points.json" },
};

answer.schema.json is a copy of @intentius/chant/workspace/point-answer.schema.json. chant workspace check validates the points file of every declared answer kind, and reports a broken one as WSP116.

chant never calls a model on a read. The call belongs to the operations layer: chant’s decide Op activity, or a runtime’s decider, which hands chant the backend’s response:

Terminal window
chant workspace points ask slice-tier --inputs inputs.json --subject W-002 --response response.json

inputs.json holds the values, keyed by input name. response.json is the backend’s POST /v1/systemone response. Without --response the model decider is not asked, and a question the table doesn’t answer escalates to people.

The answer is written as a record in answers/, named for the point and its inputs’ hash. Its state depends on who answered.

Answered byState
a table rowanswered
a model at or above its thresholdproposed, whatever its confidence, with its probabilities, confidence, threshold and the model’s reason when it gave one
nobody before the quorumescalated, with each decider’s reason and any model’s answer below its threshold

Every record carries a source block (#2708) with via and, when a model answered, its model. The same point, declaration and inputs are answered once. Asking again returns the record that is there. Editing the point changes its version, which asks the question anew, and the old answers get answer-point-changed.

A question is open while it is escalated or proposed. chant workspace points --open --json lists them with any model’s answer and confidence, so hud can prompt a person. The model’s reason comes with its answer, so the person also sees why it chose. chant serve mcp serves the same list as the workspace-points tool.

Terminal window
chant workspace points answer slice-tier-074b660bbaad --answer medium --by alice

Giving the answer the model proposed confirms it. Any other answer is the quorum’s, and the proposal is kept in the record’s escalations. The quorum counts distinct people. It never counts anyone holding the agent role in the trust policy, and when it names roles it counts only their holders. --note keeps what they say with the answer.

An answered question never changes in place. People who want to change an answer retract it, and the question is open for them again (#3351):

Terminal window
chant workspace points retract slice-tier-074b660bbaad --by alice --note "Sized against the wrong work item."
chant workspace points answer slice-tier-074b660bbaad --answer large --by alice

The retracted answer stays in the record’s retractions with who gave it, its note, and who took it back, when and why.

An Op asks a point from one of its activities with askPointInRun from @intentius/chant/op (#2749). The decide activity calls it. An answered question returns its answer, and the Op goes on. An escalated or proposed question is still open, so the activity throws PointWait. The run then ends with status waiting, the way a gate ends it with gated. No later step runs, no onFailure phase runs, and the run’s ledger record names the question as point. chant run exits 3. Nothing waits in the meantime. Once a person has answered, the next run asks the same point with the same inputs and reads the answer from its record.

import { askPointInRun, type BrokeredModelAsk } from "@intentius/chant/op";
// The caller's own call to the backend, through the broker for via.capability.
// It holds no key: the broker does.
const ask: BrokeredModelAsk = async (request, via) => postThroughBroker(via, request);
export async function chooseTier(args: { item: string; inputs: Record<string, unknown> }) {
const { answer } = await askPointInRun({ cwd: process.cwd(), point: "slice-tier", inputs: args.inputs, subject: args.item, ask });
return { tier: answer };
}

The model call is a BrokeredModelAsk the caller supplies. It is handed the box capability to reach the model through, inference unless capability names another, and the broker the caller read for it. chant makes no model call of its own.

A steward runs unattended, so a point asked during its turn follows three more rules:

  • The model call goes through the steward’s broker. It is made only when the steward names the capability in capabilities (#2726). A steward that names none makes no model call. Neither does one that holds a vault. The model decider is then recorded as not reached, and the question goes to people.
  • The question is written on the chant/lifecycle branch, not in the checkout (#2786). A steward never writes the checkout’s working tree, which belongs to the coding agent, and an Op’s leased worktree is removed when the run ends. The record goes under _answers/<kind name>/<id>.md in the ledger of the member that owns the answer kind. points reads it as if it were in the kind’s directory and gives its place on the branch as ledger. A person’s answer to it is written there too. points ask run as a child process of the turn (with CHANT_STEWARD set) does the same.
  • The question names the steward and its run in its source block, and points --open shows them as askedBy. workspace status --json lists the question in the steward’s waiting, and hud asks a person there. The question never goes to the steward’s own thread.
  • The steward never answers it. points answer is refused with answer-in-steward-turn during a steward’s turn and in any process the turn starts, and the quorum never counts the steward that asked. A local steward runs the Op again on the first round after a person answers.

decide is chant’s Op activity for asking a point (#2740). The step builder comes from @intentius/chant/op, and chant run resolves the activity in any project without a lexicon (#2828).

import { Op, phase, decide } from "@intentius/chant/op";
export default Op({
name: "tier-work",
overview: "Ask which builder tier builds W-002",
phases: [phase("Decide", [decide("slice-tier", { read: { "work-item": "W-002" }, subject: "W-002" })])],
});
ArgumentMeaning
pointThe point’s name, as its points file declares it. The builder takes it first.
inputsInput values by the point’s input names, such as { "work-item.fits_small": false }. They win over values read with read.
readWhat to read through the read contract, by output. { "work-item": "W-002" } reads work item W-002 for every work-item.* input. record, decision and work-item are read by id, and member by name. Other outputs go in inputs, as the read contract printed them.
subjectWhat the question is about, such as a work item’s id. It is written to the answer’s constrains.
kindThe answer kind file, or a declared kind’s name. Without it, the declared answer kind whose points file declares the point.
cwdWhere the workspace is found. Defaults to the working directory.
backendsBackends by name, in place of decide.backends in chant.config.ts.
dryRunAsk, but write nothing, and return the question whatever its state.

OPS012 checks a step’s arguments against the activity’s contract at chant build, so a misspelled argument or a key written as a string fails the build. Before anything is asked, the step checks that every backend the point’s model deciders name is configured, and that a brokered key’s capability is declared with a broker. It then reads the inputs and asks the point through askPointInRun, as above. An answered question is the step’s result: { id, path, state, open, answer, decider, model, backend, confidence, threshold, reason, answeredBy, escalations, missing }, where missing lists declared inputs the read value did not have.

The backends live in chant.config.ts under decide.backends. A model decider’s backend is a name in this map.

export default {
decide: {
backends: {
systemone: { url: "https://api.typesafe.ai", key: { env: "TYPESAFE_API_KEY" } },
local: { url: "http://127.0.0.1:8080" },
studio: { url: "http://127.0.0.1:7071", key: { capability: "inference", member: "box" } },
},
},
} satisfies ChantConfig;
FieldMeaning
urlThe server’s base URL. /v1/systemone is appended.
keyWhere the bearer key comes from. Omitted for a server that takes none.
timeoutMsHow long to wait before the backend counts as unreachable. Default 30000.

A key is { env: "VARIABLE" }, read when the step runs, or a capability a box member declares as brokered, { capability, member?, env? } (#2726). With env, the broker sets that variable for the Op’s process. Without it, the broker is the endpoint and adds the key itself, so none is sent. A key is never a string: the config fails to load, and so does a step whose backends holds one.

The request is the wire format’s: POST <url>/v1/systemone with { "model", "state", "questions": { "<point>": { "type", "instructions", "criteria" } } }, the point’s inputs as the state. The answer is read from answers["<point>"], where points ask --response reads it, with its optional reason. The backend counts as unreachable when the call fails or times out, when the key’s variable is unset, or when the response is not a 2xx with the answer in it. The model decider’s unreachable then decides: "escalate", the default, leaves the question open for people with the reason, and "fail" writes nothing and fails the step with point-decider-failed. In a steward’s turn a backend keyed by an environment variable is not called, since the steward would hold its key.

RuleFires on
SYS001A string or template literal where a key goes, in decide.backends or in a decide step’s backends.
SYS010A backend with a key and an http:// URL whose host is not loopback, in decide.backends or in a decide step’s backends. The bearer key would cross the network in clear text. 127.0.0.1, ::1 and localhost are exempt.

chant lint reports SYS001 over source as an error, and chant build and chant lint report SYS010 as an error along with the Op checks.

startStubBackend() from @intentius/chant/op/__fixtures__/decide-stub-backend starts a server on a free loopback port that speaks the wire format. It answers 401 to a wrong bearer when a key is set, 422 to a body outside the wire format, and a forced status when the test sets one. Otherwise it answers each question with what the test scripts, or with a confident answer of the question’s own type, and keeps every request in requests. packages/core/src/op/activities/decide.live.test.ts runs against a real server when TYPESAFE_API_KEY is set.

The reference workspace declares nine points in decisions/points.json. One, understand, gates every build of a person’s ask (#3150). Four are about the work graph that graph --intent exposes (#2741), and three are the questions a person answers while walking a region (#3351). The last is ad hoc, for an agent’s question to the people working with it (#3403).

PointQuestionReadsDeciders
finding-triagea choice of work-item, needs-a-decision or leave for one findingfinding.code, finding.message, finding.addressed, region.path, region.member, region.generatedtable, model, quorum
needs-a-decisiona noul: does the change need a decision before it landsthe commits in the change’s window (commit.count, commit.undecided, commit.decided_by_window), region.path, region.generated, work-item.implementstable, model, quorum
slice-tiera choice of builder tier for a work item (ws-057)the work item’s size and whether it fits each tiertable, model, quorum
understanda choice of proceed, redraft, ask or refuse: is a person’s ask understood well enough to build (#3150)work-item.state, title, source.ask.said, source.ask.by, source.ask.via, acceptancetable, quorum
ship-skipa noul: may a release pass the ship gate without a personwhat the release would changetable, quorum
intent-origina choice of carried-out-decision, incidental or unknown: where a commit no decision’s window holds came fromregion.id, node.idquorum
intent-judgmenta choice of drift, unwritten-supersession, decision-wrong, no-gap or not-decidable: the gap at a decision, or at a commit in a decision’s window that is not its own workregion.id, node.idquorum
intent-dispositiona choice of handled, skipped or needs-discussion: what was done at any other node of the walkregion.id, node.idquorum
agent-questionan ad-hoc choice: the question and options an agent asked, which come with each askask.id, ask.byquorum

A finding that a work item already addresses is left by the triage table. intent-decision-unimplemented becomes a work item, while intent-region-unconstrained, intent-decision-contested and intent-decision-superseded-live go to a decision. So does change-out-of-scope from check --changes, because the change crossed a boundary that a record draws, and someone decides whether the boundary moves or the change goes. Any other finding goes to the model, then to people. That includes change-uncovered, since the right answer for an uncovered change depends on what the path is. For needs-a-decision, the table says no when nothing changed in the window, or when every commit in it is a decision’s own work.

slice-tier reads five sizes of a work item, and every orchestrator has to compute them the same way, so chant does (#3150). When the decide activity reads a work-item by id, the item comes with them, and an Op passes no values of its own:

InputHow chant derives it
work-item.criteriathe number of acceptance criteria, in the field the work kind’s work.acceptance.field names (acceptance otherwise)
work-item.filesthe distinct paths the item’s text names in backticks: a token with a / between two parts, such as app/server.ts, or a file name with a one-to-five-letter extension, such as README.md
work-item.wordsthe words in the item’s text
work-item.fits_<tier>whether criteria, files and words are each within that tier’s limits

The item’s text starts with what the person asked: source.ask.said, or source.intent.answer for the box’s first build. Then come the record’s Markdown body without its heading lines and the text of each acceptance criterion. The limits are the work kind’s work.tier.limits, or the defaults: small holds at most 10 criteria, 5 files and 150 words, and medium 20, 10 and 300. A tier without limits, such as large, has no fits_ input. An item that fits neither table row goes on to the model. An item that names its own tier keeps it, and the factory asks this point only for an item without one. measureWorkItem and sizeFields in workspace/work-size.ts are the code.

A reader such as hud walks a region with graph --intent and asks one question at each node, by node kind. A commit in a decision’s window that is not the decision’s own work (decided-by-window) and a decision get intent-judgment, any other commit gets intent-origin, and any other node gets intent-disposition. Each reads region.id, the region’s id, and node.id, the node’s, as the walk names them: node is any node of the intent graph. Only people answer, so each point is a quorum of one. The reader asks the point, then records the person’s answer with their note:

Terminal window
echo '{"region.id":"region:app/server.mjs:1-20","node.id":"commit:4f1c2a9"}' \
| chant workspace points ask intent-origin --inputs - --subject commit:4f1c2a9
chant workspace points answer intent-origin-<hash> --answer incidental --by github:alice --note "A rename."

The same region and node is one question, so asking again finds the record. Clearing an answer is points retract, which keeps it in the record’s history. A workspace that wants other answer sets declares its own points with these inputs, and its reader asks those instead.

When an agent asks the people working with it a question, as hud’s hud_ask and AskUserQuestion do, the reader asks agent-question with the agent’s question and options, and records the answer of the person who picked one (arugula-salad/hud#813). ask.id is the reader’s own id for the ask, so each ask is a question of its own, and ask.by is who asked:

Terminal window
echo '{"question":"Ship the importer behind a flag?","criteria":{"flag":"Behind a flag, off by default.","now":"On for everyone."}}' \
| chant workspace points ask agent-question --inputs '{"ask.id":"req-7f3a","ask.by":"hud:chat-12"}' --candidates - --subject hud:chat-12
chant workspace points answer agent-question-<hash> --answer flag --by github:alice

The candidates are the option ids, and each criterion says what the option means. points --json lists the question with asked, its answer and who gave it, so the answer outlives the reader’s own store.

ship-skip came from chud’s points.yaml. Its table says no to every release until someone adds a row above the last one, and its quorum is the approver count a ship gate reads, as chud’s release Op read it. It has no model decider, because a model’s answer may route or report and never authorizes a release. chud’s contracts_changed became release.work_changed, since chud’s contracts are work items here.

A runtime such as the studio kit walks a region with graph --intent and asks finding-triage for each finding, through the decide activity. When the model answers work-item at or above its threshold, the answer is a proposal, and the run waits on it. The runtime then writes the work item it proposes, in the work kind’s first state, proposed, naming the decider as its proposer:

Terminal window
chant workspace records new work --from item.json --by jev-1.13.0

item.json gives state: "proposed" and a source naming the finding, its region and the triage answer’s id as answer. --by fills the work item’s proposed_by, as the kind’s proposedBy declares (#2763). A proposed item is never ready, so no builder takes it. A person keeps it by confirming the answer and opening the item:

Terminal window
chant workspace points answer <answer id> --answer work-item --by alice
chant workspace records amend W-003 --kind work --set keep.json # {"state": "open"}

Or the person gives another answer and drops the item. Once the item is committed, graph --intent shows the finding addressed by it, and asking the triage again for that finding, the table answers leave. test/reference-workspace.test.ts runs this flow against the stub backend.

chant workspace check --changes reports change-uncovered and change-out-of-scope findings, and the same point triages them (#2794). Each finding in its --json document carries addressed, read the way graph --intent reads it: a work item addresses the finding when its source.finding is the finding’s code and its source.region is the path or a directory above it. Each changed path carries member and generated. A runtime builds the inputs from the finding and its path, or in TypeScript with changeFindingTriageInputs(finding, path) from @intentius/chant/workspace/changes, and asks the point through the decide activity as above. The work item it writes names the finding’s triage as its source. Once the item is committed, a check whose head includes it covers the path when the item’s constrains names it, and otherwise shows the finding addressed, so the table leaves it.

chud’s decisions/points.yaml validates unchanged apart from the input names. Write it as JSON and give each input a read-contract output as its prefix. chud’s fits_small becomes work-item.fits_small, and its first_release becomes release.first_release. The when keys of its table rows follow. chud’s chud/decisions records stay chud’s. New answers are chant records.

chant workspace upgrade makes this move for a repo made from chud’s template, or the studio kit’s copy of it, through its chud-lexicon-exit migration. It also writes the answer kind and has the release Op ask ship-skip through decide. See Migrations chant ships. A point of the repo’s own whose inputs have no known output is a conflict, left for you to write by hand.