The decide() seam
The decide() seam
Section titled “The decide() seam”scripts/lib/decide.js is the one way BoB asks a small model a typed question
about a state. A caller states its question and its state; the seam decides which
backend answers, whether the state may leave the machine at all, and what the
answer’s confidence permits.
It exists because scripts/lib/jev.js had exactly one caller
(scripts/warp-drive/issue-dedupe.js) and that caller knew it was talking to
Jev — it phrased its question the way Jev reads one and routed on Jev’s
confidence scale. Briefing #2798
put this first of everything it would file, for one reason: at one call site that
coupling is free, and at ten it is a migration. The seam landed before the
second call site.
The contract
Section titled “The contract”const { decide } = require('./decide');
const r = await decide({ state: { new_issue: { title, body } }, questions: { duplicate_of: { type: 'choice', instructions, criteria } }, caller: 'groom-dedupe',});
if (!r.decided) { if (r.record_required) record(r.abstention); // a judge must say it abstained fallback(r.reason);} else if (r.answers.duplicate_of.route === 'act') { ...}The question vocabulary is the contract: choice (one of a set), score (a
position on ordered levels) and noul (the probability a yes/no holds), each
answered with a confidence. Jev defined that vocabulary and the seam adopts it —
worth saying plainly rather than pretending the seam is shapeless. A backend must
speak it, and every backend’s answers are validated by the same function
(jev.normalizeResponse).
A decided answer:
| Field | What it is |
|---|---|
schema |
decision/1 |
decided |
true |
caller, backend, model |
who asked, what answered, which version |
stakes, fail |
the mode the call ran under, from the caller’s declaration or derived from the action |
stakes_source |
declared (the registry), explicit (the call) or classify (derived from the action) |
classification |
the classify() verdict the stakes were checked against — present only when an action was passed |
answers[id] |
{ answer, confidence, route, threshold, reason } per question |
route |
the weakest route across the set — one human makes the whole decision human |
confidence |
the lowest confidence in the set |
usage, latency_ms |
what it cost and how long it took |
An undecided answer carries decided: false, a reason, route: 'human',
record_required, and — when recording is required — an abstention record.
decide() throws only on a programming error (an undeclared caller, unknown
stakes, stakes lower than the action derives, a stakes_from caller that passes
no action, an unknown backend, a missing fail mode). It never throws because a
backend failed.
Backends
Section titled “Backends”The backend is configuration, resolved once: opts.backend → BOB_DECIDE_BACKEND
→ the caller’s declaration → jev.
| Backend | Built | What it is |
|---|---|---|
jev |
yes | TypeSafe Jev through scripts/lib/jev.js, unchanged — one batched request per state, pinned model, injectable transport |
deterministic |
yes | opts.rule(state, questions) returns the same typed answers. No network, no key, no cost — the fastest backend, and the right one whenever the question is really a rule |
haiku-forced-tool |
no | A Haiku call with a forced tool call, for questions Jev cannot express. Declared, not built — its contract is recorded in BACKENDS['haiku-forced-tool'].contract, and calling it returns not-implemented with record_required: true rather than a silent gap |
off |
yes | Never decides. The honest way to turn the seam off: every caller takes its declared fallback |
node scripts/lib/decide.js backends [--json] # every backend, which is built, which would answernode scripts/lib/decide.js callers [--json] [--callers <file>] # every declared call site, with the tier a stakes_from caller derivesnode scripts/lib/decide.js status [--json] # the resolved backend and the tiersThe caller registry
Section titled “The caller registry”scripts/lib/decide-callers.json is the declaration of every call site. A caller
absent from it cannot call — the seam refuses rather than sending an
undeclared state. Adding a call site is therefore a declaration, not a keystroke.
"groom-dedupe": { "purpose": "Which older open issue asks for the same work as a new one?", "allow": ["new_issue.title", "new_issue.body"], "stakes": "mutating", "fail": "open", "why_fail": "A gate. Failing open yields one extra issue, which a person can close."}Declared callers
Section titled “Declared callers”| Caller | Asks | May send | Stakes | Fail | Why that fail mode |
|---|---|---|---|---|---|
groom-dedupe |
Which older open issue asks for the same work as a new one? (scripts/warp-drive/issue-dedupe.js, #2681). The issue’s declared parent and a cap’s listed children are never offered as candidates (#2985). |
new_issue.title, new_issue.body |
mutating | open |
A gate: failing open yields one extra issue, which a person can close; failing closed would stall grooming. |
brief-next-action |
Which of a brief’s candidate next actions comes first? (scripts/brief/, #2818) |
brief.title, .summary, .priority, .age_days, .state, .actions |
mutating | visible |
A judge: an abstention that read as approval would silently degrade the surface it sharpens. |
decompose-testability |
Is each acceptance criterion on a drafted child observable — actor, action, result? (scripts/decompose/testability.js, #2977) |
child.title, child.acceptance_criteria |
mutating | visible |
A judge: “looks testable” by default would degrade every child filed with no signal at all. |
decompose-testability is the decomposition machine’s (#2973) testability gate.
One noul question per criterion goes out in one request; the per-criterion answer,
confidence, route, backend and model are recorded on the run state. A criterion
answered not observable, or routed human, blocks the child from filed
until it is reworded and re-judged, or a human override is written on the child’s
issue body (a <!-- decide:override decompose-testability --> block naming who
and why). The gate reads overrides only from the body, and it refuses an override
from the run’s own identity, so the machine cannot override itself or do it
silently. An undecided answer does not block: the child may be filed, but it is
marked with a <!-- decide:abstained decompose-testability --> block giving
the reason, so abstentions can be counted on the artifacts. The deterministic
backend’s rule (observableRule) passes actor + action + result and fails a bare
artefact noun, which is enough for tests and offline runs. Coverage:
tests/test-testability.js.
Egress: the state leaves the machine
Section titled “Egress: the state leaves the machine”allow is the list of state fields that may be sent. Every leaf of the state is a
dotted path; array elements share their parent’s path, so candidates[].title is
candidates.title and one entry covers the whole list. An entry covers itself and
everything beneath it. * covers the state whole and has to be written down to
mean it.
A state carrying an undeclared field is refused — reported on stderr, returned
as reason: 'egress-refused' with the offending paths named, and record_required
whatever the caller’s fail mode. Nothing is sent. It is never quietly trimmed,
because a silently trimmed state answers a different question than the one the
caller asked.
The static twin: check-decide-lint (#2982)
Section titled “The static twin: check-decide-lint (#2982)”The runtime refusal protects the machine while a call runs. It cannot see a script
that never calls the seam, and it only catches an undeclared key when a run
happens to build one. scripts/checks/decide-lint.js, run by make check as
check-decide-lint, catches both before a run:
| Offence | What it catches |
|---|---|
bypass |
a require(...jev.js), or jev.evaluate( / jev.batch( on any name bound to it, outside scripts/lib/decide.js and scripts/lib/jev.js |
undeclared-key |
a decide() call whose state is a literal object, or the one object a same-file builder function returns, carrying a key outside that caller’s allow — checked with the seam’s own coverage rule (refusedPaths) |
undeclared-caller |
a decide() call naming a caller absent from decide-callers.json |
Each offence is named with its file and line, and the lint exits 1. A call whose
state or caller it cannot resolve (a variable, a spread, a builder from another
file) is listed as unverified and counted on the summary line
(4 decide() call(s) — 4 verified, 0 unverified). It does not fail the build and
it is never passed silently: keep a call site’s state a literal or a same-file
builder so the lint can read it.
It scans scripts/, hooks/, bin/, apps/, registry/, skills/ and
templates/; tests/ is not scanned. Comments and strings are not code. A
deliberate exception goes in scripts/checks/decide-allow.json, in the
abs-path-allow.json shape (path, optional pattern and rule, a required
why). Today it holds one entry: the llm-tiering skill’s Jev example, which
measures the backend itself.
node scripts/checks/decide-lint.js # report; exit 1 on an offencenode scripts/checks/decide-lint.js --json # decide-lint/1 documentnode scripts/checks/decide-lint.js --root DIR [--callers FILE] [--allow FILE]Fail-open is the caller’s choice, never a default
Section titled “Fail-open is the caller’s choice, never a default”fail has no default and the seam refuses a caller that has not declared one.
open— a gate. An undecided answer means carry on. A missed duplicate costs one extra issue, which a person can close.visible— a judge. An undecided answer is an artifact:record_required: true, anabstentionrecord naming the caller, the backend, the reason and the time, andabstentionNote(result)for the one line to write onto the artifact.
This is #2798’s first blind spot, made structural. A judge that fails open to “looks fine” degrades quality with no signal at all — the worst failure mode there is, because it is indistinguishable from success. “The machine had no opinion” must never read as “the machine approved it”.
Routing
Section titled “Routing”Routing happens once, here, so no call site re-implements the tiers. The tiers are
jev.js’s — one declaration, not two.
| Stakes | Acts at | Otherwise |
|---|---|---|
read-only |
≥ 0.5 | below the 0.5 floor → human |
mutating |
≥ 0.8 | 0.5–0.8 → confirm; below the floor → human |
destructive |
never | always human, whatever the confidence |
A caller may raise its own bar with thresholds (the dedupe gate ships at 0.85)
but cannot lower the floor. Across several questions the weakest route wins.
Stakes from reversibility (#2981)
Section titled “Stakes from reversibility (#2981)”Two engines grade a decision on two different axes:
- Reversibility —
scripts/warp-drive/decision-engine.jsclassify()(Reversibility Decision Engine): can this be undone? It answersone-wayortwo-way, and anything it does not recognise isone-way. - Confidence —
jev.route(), through this seam: is the model sure enough for the stakes?
Reversibility picks the threshold, and confidence has to clear it. A caller passes
the action it is deciding about (the descriptor classify() reads:
{ description, command } or a string), and the seam derives the stakes:
classify() verdict |
Stakes | Route |
|---|---|---|
two-way, read_only: true (a query, a report, a dry run) |
read-only |
acts at ≥ 0.5 |
| two-way | mutating |
acts at ≥ 0.8, else confirm |
| one-way | destructive |
always human |
| unknown (one-way by the fail-safe) | destructive |
always human |
const r = await decide({ caller: 'my-caller', state, questions, action: { description: 'close the duplicate issue' } });// r.stakes === 'mutating', r.stakes_source === 'classify', r.classification.door === 'two-way'The thresholds stay in one place, jev.js TIERS. The seam maps a verdict to a
tier name and never holds a number of its own.
Read-only is the narrow verdict. It is a two-way category checked after every category that changes something. It never matches when a mutating verb (delete, write, create, update, apply, deploy, …) appears beside the read verb, so an action such as “list the stale records and delete them” is never graded read-only. Getting it wrong raises the stakes, never lowers them.
A written stakes may raise the tier, never lower it. When an action is passed,
a stakes written in the call or declared in the registry that is lower than
the derived one is refused as a programming error. A higher one wins
(stakes_source names where the stakes came from). A call with no action keeps the
caller’s declared stakes unchanged, so every existing caller behaves as before.
In the registry, a caller declares "stakes_from": "classify" instead of a
literal. It may also declare a standing "action", which is used when a call
passes none:
"my-caller": { "purpose": "…", "allow": ["…"], "stakes_from": "classify", "action": { "description": "query the open issues" }, "fail": "open"}decide.js callers prints the tier that standing action derives, for example
[stakes read-only (from classify: two-way, read-only), fail open]. With no
standing action it prints derived per call, and the JSON carries
derived_stakes: null. A stakes_from caller that passes no action and declares
none is refused.
Testing
Section titled “Testing”tests/test-decide.js (90 assertions) runs with no key and no network: every
backend is the deterministic rule or a fake transport. It pins the answer
document, backend resolution, the unbuilt backend’s declaration, routing and the
weakest-route rule, the refusal of an undeclared fail mode / stakes / caller, that
a refused egress sends nothing, the CLI, and stakes derived from reversibility:
each verdict’s tier, the refusal of a lower written stakes, the fail-safe route, and
the callers output for a stakes_from caller.
tests/test-decide-lint.js drives the lint against a clean and a dirty fixture
tree (tests/fixtures/decide-lint/) and against this repository.
tests/test-issue-dedupe.js is the migration proof: its 39 assertions pass
unchanged across the move from jev.evaluate() to decide().
See also
Section titled “See also”registry/skills/llm-tiering/SKILL.md— when a question belongs to a small model at all- Reversibility Decision Engine — the other axis: reversibility picks the threshold that confidence must clear
scripts/lib/jev.js— the reference backend and the answer shape