docs← Back to article

Markdown for LLMs

Collect missing facts: ask for exactly what the rules need

The source Markdown for this article. Copy it into your assistant or download it as a text file.

Download this articlePlain text ↗
# Collect missing facts: ask for exactly what the rules need

## At a glance

- **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](/build/handle-results/) — a partial
  run answers `NEITHER`, which this chapter treats as the start of a
  question, not as an outcome; [Bind input](/build/bind-input/)
  supplies the draft contract.
- **Run:**

  ```bash
  node --import tsx --test test/missing.test.ts
  ```

- **Files:**

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

## Ask for what a draft is missing

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

```bash
node --import tsx src/cli.ts explain --candidate 2026-03-20 --event 2026-03-06
```

```text
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:

```bash
node --import tsx src/cli.ts explain --candidate 2026-03-20
```

```text
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:

```text
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.

## Explain a draft instead of evaluating it

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":

```ts
// 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.

## Read the engine's report

`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.

## Route, need, askable

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.

## Walk one blocker to one question

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 askable allowlist

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:

```ts
// 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',
  },
];
```

## Four checks for each need

`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:

```ts
// 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

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.

## Limits and errors

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

## Check your understanding

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

```bash
node --import tsx --test test/missing.test.ts
```

```text
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.

<details>
<summary>The draft has only the duration (`--days 14`). What does `explain` ask?</summary>

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`.

</details>

<details>
<summary>All fields are filled, but the candidate is 2026-03-21. Does the questionnaire keep asking?</summary>

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.

</details>

<details>
<summary>`frist_ende_kalender` is UNDETERMINED in the report. Why is it never asked?</summary>

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.

</details>

## Next

- [Show grounds: proof, sources, hashes](/build/show-grounds/)
- [Connect an AI assistant](/guide/mcp/) for the same inventory over MCP
- [Query types](/build/sdk/query-types/): the `why_not` row explains the blocker report