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
Section titled “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. 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, DraftInputsrc/to-case.ts ADAPTER_VERSION 1.0.0, toFacts, toCaseInput, toDraftFactspublic/index.html the form with its pre-filled example values
Map form fields to facts
Section titled “Map form fields to facts”For the example values — event on 6 March 2026, 14 days — the adapter emits exactly two facts:
[ { "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
Section titled “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):
export interface FormInput { eventDate: string; durationDays: number; candidateEnd?: string; legalTime: string;}Reject values that are not dates
Section titled “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
Section titled “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:
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
Section titled “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:
// 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:
// 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
Section titled “Limits and errors”- An unknown field is omitted, never invented: if the user leaves
candidateEndblank, 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
FactErrorand anearestlist 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
Section titled “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:
node --import tsx --test test/input-schema.test.tsok 3 - rejects a non-calendar dateok 4 - rejects an empty string, never defaults itok 8 - a draft keeps absent fields absent, never defaultedok 12 - the case pins the axis, the time zone, and the policy# tests 13# pass 13# fail 0Before 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.
- Calculate and check
- How to read an answer
- Facts and context for the full
FactandCaseInputcontract
Documentation for Arxo. Writings — blog.arxo.io.
Anonymous visit counts on stats.arxo.io, no cookies.