docs← Back to article

Markdown for LLMs

Bind input: from form fields to facts

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

Download this articlePlain text ↗
# Bind input: from form fields to facts

The form has four fields: the event date, the duration in days, an
optional candidate end date, and the legal time. This chapter turns them
into the facts the canon reads, and rejects anything that is not a real
date before the engine ever sees it.

## At a glance

- **Goal:** turn form fields into canon facts with a versioned adapter,
  validate them strictly, and keep a half-filled form apart from a
  complete one.
- **You need:** the unpacked archive from [Start](/build/start/). This
  chapter explains the validation and the adapter; the calls that use
  them arrive in the next chapter.
- **Run:**

  ```bash
  node --import tsx --test test/input-schema.test.ts
  ```

- **Files:**

  ```text
  src/input-schema.ts     validateFormInput, FormInput, validateDraftInput, DraftInput
  src/to-case.ts          ADAPTER_VERSION 1.0.0, toFacts, toCaseInput, toDraftFacts
  public/index.html       the form with its pre-filled example values
  ```

## Map form fields to facts

For the example values — event on 6 March 2026, 14 days — the adapter
emits exactly two facts:

```json
[
  { "predicate": "frist_ereignis", "args": ["frist", "2026-03-06"] },
  { "predicate": "frist_dauer_tage", "args": ["frist", "14 calendar_day"] }
]
```

Only two of the four fields become facts. Each field has its own place
in the call:

| Form field | Example | Becomes |
|---|---|---|
| `eventDate` | `2026-03-06` | a fact about the event: `frist_ereignis` |
| `durationDays` | `14` | a fact about the duration: `frist_dauer_tage` |
| `candidateEnd` | `2026-03-20` | an argument of the claim being checked, never a fact |
| `legalTime` | `2026-09-17` | the context of the computation, not a fact |

## Keep the two dates separate

Two dates in this application are easy to confuse and must stay apart.
`eventDate` (`2026-03-06`) is a fact: the day the event happened. It
enters the rules. `legalTime` (`2026-09-17`) is the date as of which
Arxo reads the law: it selects the version of the law, not the outcome
of the count. They serve different purposes. The form shows both as
pre-filled fields; only `eventDate` enters the rules.

Because `legalTime` fixes the point in time at which the law is read,
the code calls it the legal axis. The form type keeps the raw user input
apart from the evaluated case. `candidateEnd` is optional; `legalTime`
is required — the axis is always fixed, never defaulted by the validator
(the form pre-fills it with `2026-09-17`, but the value still travels
explicitly):

```ts
// src/input-schema.ts
export interface FormInput {
  eventDate: string;
  durationDays: number;
  candidateEnd?: string;
  legalTime: string;
}
```

## Reject values that are not dates

Validation is strict on purpose. `2026-02-30` is not a date, `14.5`
is not a day count, and an empty string is not "no candidate" — the
field is either a valid date or it is omitted. The engine must never
receive a value the form would not show back to the user.

Validation returns a discriminated union — `{ ok: true; value }` or
`{ ok: false; errors }` — so the caller cannot forget the failure
branch.

## Build the facts from the form

The adapter gives the period a stable id and builds the facts. The id
`frist` is the application's choice: one form submission is one period,
and every fact about it carries the same id so the rules join them.
`toCaseInput` adds the legal time, and pins the timezone and the
deadline policy the canon declares:

```ts
// src/to-case.ts
export function toFacts(input: FormInput, id: string = CASE_ID): Fact[] {
  return [
    // The event that starts the period (§ 187 (1) BGB).
    { predicate: 'frist_ereignis', args: [id, input.eventDate] },
    // The length of the period in days (§ 188 (1) BGB).
    { predicate: 'frist_dauer_tage', args: [id, `${input.durationDays} calendar_day`] },
  ];
}

export function toCaseInput(input: FormInput, id: string = CASE_ID): CaseInput {
  return {
    legalTime: input.legalTime,
    timezone: TIMEZONE,
    deadlinePolicy: POLICY,
    answers: toFacts(input, id),
  };
}
```

The quantity string `'14 calendar_day'` uses the unit name the canon
declares; a different spelling is a different value.

## Handle an incomplete form

A draft is the same form before it is complete: either fact may be
absent, and absent stays absent — never defaulted, never guessed.
Only the legal axis is required, because the question "what is still
missing?" is always asked as of a fixed date:

```ts
// src/input-schema.ts
// A draft: the axis is always fixed, but either fact may still be
// unknown. A draft can be explained (what is missing?) but never
// evaluated as a complete case.
export interface DraftInput {
  eventDate?: string;
  durationDays?: number;
  legalTime: string;
}
```

The draft adapter emits only what the draft actually knows. For a draft
with only the event, `toDraftFacts` emits exactly one fact — the
duration fact is absent, not zeroed:

```ts
// src/to-case.ts
// Draft facts: only what the draft actually knows becomes a case fact.
// An absent field stays absent — it is never defaulted or guessed.
export function toDraftFacts(draft: DraftInput, id: string = CASE_ID): Fact[] {
  const facts: Fact[] = [];
  if (draft.eventDate !== undefined) {
    facts.push({ predicate: 'frist_ereignis', args: [id, draft.eventDate] });
  }
  if (draft.durationDays !== undefined) {
    facts.push({ predicate: 'frist_dauer_tage', args: [id, `${draft.durationDays} calendar_day`] });
  }
  return facts;
}
```

## Limits and errors

- An unknown field is omitted, never invented: if the user leaves
  `candidateEnd` blank, the application asks no truth question at
  all. An omitted question is not a negative answer.
- A negation is explicit or it does not exist. A form control that
  denies something must record that denial as its own state (for
  example `state: 'no'`); "the user did not tick yes" is not a denial
  and must never be converted into one by default.
- Unknown predicate names fail at the call with `FactError` and a
  `nearest` list of real names. The adapter is the one place that
  maps form fields to predicate names, so a rename breaks here first
  and loudly — which is what chapter 8 relies on.
- A draft is explainable but not evaluable: chapter 5 turns it into
  questions, but no collect or truth call ever runs on it.

## Check your understanding

The tests submit `2026-02-30` as the event date and confirm the
application refuses it with the field name before any engine call:

```bash
node --import tsx --test test/input-schema.test.ts
```

```text
ok 3 - rejects a non-calendar date
ok 4 - rejects an empty string, never defaults it
ok 8 - a draft keeps absent fields absent, never defaulted
ok 12 - the case pins the axis, the time zone, and the policy
# tests 13
# pass 13
# fail 0
```

Before reading each answer, predict it.

<details>
<summary>Where does candidateEnd go: into the facts or into the question?</summary>

Into the question. `toFacts` emits only the event and the duration;
the candidate becomes an argument of the truth query in the next
chapter. If the field is blank, no truth question is asked at all.

</details>

<details>
<summary>You change legalTime from 2026-09-17 to another date. Do the facts change?</summary>

No. The two facts stay the same: `legalTime` travels in the case input
beside them, as the date the law is read on. It can change which
version of the law applies, but it never becomes a fact about the
event.

</details>

<details>
<summary>A user submits durationDays as 14.5. What reaches the engine?</summary>

Nothing. The validator rejects the form with
`durationDays must be an integer from 1 to 36500`, and no engine call
runs. The value is not rounded or defaulted.

</details>

## Next

- [Calculate and check](/build/calculate-and-verify/)
- [How to read an answer](/guide/reading-an-answer/)
- [Facts and context](/build/sdk/facts-and-context/) for the full `Fact` and `CaseInput` contract