Skip to content
docs
Arxo ↗

Bind input: from form fields to facts

For LLMs9 sections

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.

  • 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. This chapter explains the validation and the adapter; the calls that use them arrive in the next chapter.

  • Run:

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

    Output
    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

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 fieldExampleBecomes
eventDate2026-03-06a fact about the event: frist_ereignis
durationDays14a fact about the duration: frist_dauer_tage
candidateEnd2026-03-20an argument of the claim being checked, never a fact
legalTime2026-09-17the context of the computation, not a fact

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

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

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.

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:

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.

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:

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:

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;
}
  • 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.

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

Terminal
node --import tsx --test test/input-schema.test.ts
Output
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.

Where does candidateEnd go: into the facts or into the question?

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.

You change legalTime from 2026-09-17 to another date. Do the facts change?

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.

A user submits durationDays as 14.5. What reaches the engine?

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.

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

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