Skip to content
docs
Arxo ↗

Collect missing facts: ask for exactly what the rules need

For LLMs12 sections
  • Goal: when facts are missing, ask the user for precisely the missing fields — and nothing else. Whatever the rules need beyond the form is reported with a reason, never asked, never silently dropped.

  • You need: Handle results — a partial run answers NEITHER, which this chapter treats as the start of a question, not as an outcome; Bind input supplies the draft contract.

  • Run:

    Terminal
    node --import tsx --test test/missing.test.ts
  • Files:

    Output
    src/runtime.ts explainWhy, explainDraft
    src/missing.ts ASKABLE allowlist, missingFacts
    src/cli.ts the explain command (draft over flags)

A user has typed the event date but not the duration yet. Explain that draft against the proposed end date:

Terminal
node --import tsx src/cli.ts explain --candidate 2026-03-20 --event 2026-03-06
Output
ask: durationDays — the length of the period in days, e.g. 14
cannot ask (not-askable): urn:de:corpus:clir:bgb-fristen#frist_ende_kalender — no form field supplies this predicate; it may be model-internal

The application asks one question — the duration — and names one thing it will not ask, with the reason. With nothing known, both fields are asked:

Terminal
node --import tsx src/cli.ts explain --candidate 2026-03-20
Output
ask: eventDate — the event that starts the period, e.g. "2026-03-06"
ask: durationDays — the length of the period in days, e.g. 14
cannot ask (not-askable): urn:de:corpus:clir:bgb-fristen#frist_ende_kalender — no form field supplies this predicate; it may be model-internal

The four cases the tests pin:

Output
draft with event only ..... ask durationDays
empty draft ............... ask eventDate, durationDays
complete draft ............ ask nothing (verdict none-askable)
computed intermediate ..... reported not-askable, never asked

The user answers 14, the adapter rebuilds the case, and the evaluation from chapter 3 answers COMPUTED / TRUE_ONLY with its own fresh hashes.

A draft is explained, never evaluated. Two runtime functions do this; both call the engine’s whyNot question on the claim “the period ends on the candidate date”:

src/runtime.ts
// Explain mode: when the answer is not established, name the blockers.
export async function explainWhy(input: FormInput, candidate: string): Promise<Answer> {
const model = await openModel();
return model.whyNot('frist_ende', ['frist', candidate], toCaseInput(input));
}
// Draft mode: a partial draft can be explained — which questions are
// still open? — but never evaluated as a complete case.
export async function explainDraft(draft: DraftInput, candidate: string): Promise<Answer> {
const model = await openModel();
return model.whyNot('frist_ende', ['frist', candidate], toDraftCaseInput(draft));
}

explainWhy takes a complete form; explainDraft takes a draft and sends only the facts the draft actually knows — an absent field stays absent, it is never defaulted. The explain command uses explainDraft. Both run locally, like the evaluation calls.

whyNot answers with a list of blockers. A blocker is one rule that could establish the claim, together with the state of each of its conditions. For the draft with only the event, the report holds two blockers. Trimmed to the fields this chapter uses, it reads:

JSON
[
{
"rule": "urn:de:corpus:clir:bgb-fristen#TagesfristEnde",
"trigger": "UNDETERMINED",
"conjuncts": [
{
"trigger": "UNDETERMINED",
"literal": {
"predicate": "urn:de:corpus:clir:bgb-fristen#frist_ereignis",
"args": [{ "kind": "entity_ref", "id": "urn:de:corpus:clir:bgb-fristen:entity:frist" }, { "kind": "var", "var": "v1" }]
}
},
{
"trigger": "UNDETERMINED",
"literal": {
"predicate": "urn:de:corpus:clir:bgb-fristen#frist_dauer_tage",
"args": [{ "kind": "entity_ref", "id": "urn:de:corpus:clir:bgb-fristen:entity:frist" }, { "kind": "var", "var": "v2" }]
}
}
]
},
{
"rule": "urn:de:corpus:clir:bgb-fristen#Verlegung193",
"trigger": "UNDETERMINED",
"conjuncts": [
{
"trigger": "UNDETERMINED",
"literal": {
"predicate": "urn:de:corpus:clir:bgb-fristen#frist_ende_kalender",
"args": [{ "kind": "entity_ref", "id": "urn:de:corpus:clir:bgb-fristen:entity:frist" }, { "kind": "var", "var": "v1" }]
}
}
]
}
]

A blocker is not an error and not a verdict. The report lists the routes to the claim even when the claim is already established: on the complete draft Verlegung193 is still reported, waiting on frist_ende_kalender, while TagesfristEnde already establishes the date. A complete case asks nothing because every askable need is already satisfied, not because the blockers disappear.

Three words carry the rest of the chapter:

  • Route — one way to reach the conclusion. Each blocker is one route: here TagesfristEnde (count the days) and Verlegung193 (move an end that falls on a weekend or holiday). Satisfying one route is enough.
  • Need — a condition that route requires: one conjunct whose trigger is UNDETERMINED. TagesfristEnde needs frist_ereignis and frist_dauer_tage; Verlegung193 needs frist_ende_kalender.
  • Askable — a need the application has an input field for. This form has two: the event date and the duration.

Follow the draft with only the event through the report above:

  1. Route TagesfristEnde, need frist_ereignis. The engine marks it UNDETERMINED because the question does not fix its value, but the application sent frist_ereignis(frist, 2026-03-06). The need is about the case object frist and the sent fact covers it, so it counts as known.
  2. Route TagesfristEnde, need frist_dauer_tage. No sent fact covers it. The allowlist has an entry for this exact predicate, pointing to the field durationDays, and the value position is a free variable (v2). It becomes a question: ask: durationDays — the length of the period in days, e.g. 14.
  3. Route Verlegung193, need frist_ende_kalender. No sent fact covers it, and no field supplies it: it is a computed intermediate the rules derive once the inputs exist. It is reported as not-askable and never asked.

Only one route has askable needs, so the flat ask list is [durationDays] and the verdict is ask.

The adapter asks only what the form can supply. ASKABLE is keyed by the engine’s full predicate URNs, so a same-named predicate from another namespace never matches:

src/missing.ts
// The only questions this form is allowed to ask. Full URNs were read
// off real whyNot output of de.bgb.fristen@0.1.0, not guessed.
export const ASKABLE: AskableField[] = [
{
predicate: 'urn:de:corpus:clir:bgb-fristen#frist_ereignis',
field: 'eventDate',
hint: 'the event that starts the period, e.g. "2026-03-06"',
},
{
predicate: 'urn:de:corpus:clir:bgb-fristen#frist_dauer_tage',
field: 'durationDays',
hint: 'the length of the period in days, e.g. 14',
},
];

missingFacts(answer, sent, caseId) takes each need through four checks in order. A need about another object is other-object — a fact about this case never satisfies it. A need the sent facts already cover is known. A need no field supplies is not-askable. A need whose value position is fixed is constrained-value: the form cannot faithfully ask for one specific value as if it were open input. Only what passes all four becomes a question. The result is a report:

src/missing.ts
export interface MissingReport {
// The flat ask list. Non-empty only when exactly one route has
// askable needs; otherwise the caller reads report.rules instead.
missing: MissingNeed[];
rules: RuleNeeds[];
// Several routes need different askable facts: present a choice.
alternatives: boolean;
// 'ask' means the form can still supply something; 'none-askable'
// means it cannot — NOT that no factual gap exists.
verdict: 'ask' | 'none-askable';
// Literals skipped on an unrecognized trigger. Never silent.
ignored: IgnoredLiteral[];
}

Alternative routes, other objects, unknown triggers

Section titled “Alternative routes, other objects, unknown triggers”

When several routes need different askable facts, the flat list stays empty, alternatives is true, and the caller presents the per-route asks as a choice — alternatives never merge into one combined requirement list. For this canon the second route needs only the computed frist_ende_kalender, so the choice never occurs on the real model; a hand-built test pins the shape.

The same tests pin the rarer cases with hand-built blockers: a need about another object is reported as other-object; a predicate from a foreign namespace never matches the allowlist; a conjunct on an unrecognized trigger is recorded in ignored, never silently skipped, so a future engine report cannot shrink the ask list without leaving a trace.

  • The adapter must not ask for computed intermediates. Asking the user for frist_ende_kalender would request a value the rules derive — the form would be asking for the answer it is supposed to compute.
  • The decision to keep asking is read from verdict, never from the length of missing. An empty ask list beside NEITHER with verdict: "none-askable" means the form has no further question — not that no factual gap exists: the candidate may be wrong, or the gap may sit outside what this form can supply, and that routes to review. But an empty missing with alternatives: true and verdict: "ask" means the opposite — questions exist on several routes, and the interface must show them per route instead of stopping.

Run the missing-facts tests and confirm the ask lists:

Terminal
node --import tsx --test test/missing.test.ts
Output
ok 1 - a draft with only the event asks for durationDays
ok 2 - an empty draft asks for both fields
ok 3 - a draft with only the duration asks for eventDate
ok 4 - a complete draft asks nothing — but that is not "no factual gap"
ok 5 - a complete case with a wrong candidate ends the questionnaire on NEITHER
ok 6 - the computed intermediate is unaskable, never a question
# tests 14
# pass 14
# fail 0

Predict before you open each answer.

The draft has only the duration (`--days 14`). What does `explain` ask?

It asks for eventDate only. The duration is covered by the sent fact, so the TagesfristEnde route still needs just the event. frist_ende_kalender is still reported as not-askable.

All fields are filled, but the candidate is 2026-03-21. Does the questionnaire keep asking?

No. The truth answer is NEITHER, and the report has verdict none-askable with no alternatives: every askable need is already covered by the sent facts. The questionnaire stops and shows NEITHER; the problem is the candidate, not a missing field.

`frist_ende_kalender` is UNDETERMINED in the report. Why is it never asked?

No entry in ASKABLE points to it, so it fails the not-askable check. It is a computed intermediate: the rules derive it from the event and the duration, and asking the user for it would mean asking for the answer.

Documentation for Arxo. Writings — blog.arxo.io.

Anonymous visit counts on stats.arxo.io, no cookies.