Skip to content

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.

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.

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 answer
node scripts/lib/decide.js callers [--json] [--callers <file>] # every declared call site, with the tier a stakes_from caller derives
node scripts/lib/decide.js status [--json] # the resolved backend and the tiers

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."
}
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.

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 offence
node scripts/checks/decide-lint.js --json # decide-lint/1 document
node 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, an abstention record naming the caller, the backend, the reason and the time, and abstentionNote(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 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.

Two engines grade a decision on two different axes:

  • Reversibility — scripts/warp-drive/decision-engine.js classify() (Reversibility Decision Engine): can this be undone? It answers one-way or two-way, and anything it does not recognise is one-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.

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