Skip to content

Appointment Booking Walkthrough

A booking system where patients request appointment slots, the platform reserves them via an external clinic API, and sends confirmation emails. The system enforces that no slot is double-booked and every request reaches exactly one terminal state.

This walkthrough covers the full JacqOS pipeline:

  1. Observations arrive as JSON events (slot listed, booking requested, reservation result, confirmation result)
  2. Mappers extract semantic atoms from each observation
  3. Rules derive facts like slot_available, booking_confirmed, and booking_status
  4. Invariants enforce that no slot has two holds or two confirmed bookings
  5. Intents derive outbound actions (reserve a slot, send a confirmation)
  6. Fixtures prove the system handles happy paths, cancellations, and double-booking races
jacqos-appointment-booking/
jacqos.toml
ontology/
schema.dh # 14 relations
rules.dh # Lifecycle rules + 3 invariants
intents.dh # Intent derivation
mappings/
inbound.rhai # 5 observation kinds -> atoms
fixtures/
happy-path.jsonl # Successful booking
contradiction-path.jsonl # Cancellation mid-flow
double-booking-path.jsonl # Race condition
schemas/
booking-intake.json
prompts/
triage-system.md

jacqos.toml declares the app identity, file paths, and effect capabilities:

app_id = "jacqos-appointment-booking"
app_version = "0.1.0"
[paths]
ontology = ["ontology/*.dh"]
mappings = ["mappings/*.rhai"]
fixtures = ["fixtures/*.jsonl"]
[capabilities]
http_clients = ["clinic_api", "notify_api"]
timers = true
[capabilities.intents]
"intent.reserve_slot" = { capability = "http.fetch", resource = "clinic_api" }
"intent.send_confirmation" = { capability = "http.fetch", resource = "notify_api" }

Every outbound action is a declared capability. The evaluator rejects any intent that references an undeclared resource at load time.

ontology/schema.dh declares every relation the system can derive. This is the full schema of what the booking system knows about:

relation booking_request(request_id: text, patient_email: text, slot_id: text)
relation slot_listed(slot_id: text)
relation slot_available(slot_id: text)
relation slot_hold_active(request_id: text, slot_id: text)
relation booking_rejected(request_id: text, slot_id: text, reason: text)
relation booking_cancelled(request_id: text, slot_id: text)
relation booking_confirmed(request_id: text, slot_id: text)
relation confirmation_pending(request_id: text, patient_email: text, slot_id: text)
relation confirmation_sent(request_id: text)
relation confirmation_failed(request_id: text, reason: text)
relation booking_terminal(request_id: text)
relation booking_status(request_id: text, status: text)
relation intent.reserve_slot(request_id: text, slot_id: text)
relation intent.send_confirmation(request_id: text, patient_email: text, slot_id: text)

Relations prefixed with intent. derive outbound actions. Everything else is internal derived state.

mappings/inbound.rhai translates raw JSON observations into typed atoms. Each observation kind produces a specific set of atoms:

fn map_observation(obs) {
let body = parse_json(obs.payload);
if obs.kind == "slot.status" {
return [
atom("slot.id", body.slot_id),
atom("slot.state", body.state),
];
}
if obs.kind == "booking.request" {
return [
atom("booking.request_id", body.request_id),
atom("booking.email", body.email),
atom("booking.slot_id", body.slot_id),
];
}
if obs.kind == "reservation.result" {
let atoms = [
atom("reservation.result", body.result),
atom("reservation.request_id", body.request_id),
atom("reservation.slot_id", body.slot_id),
];
if body.contains("reason") {
atoms.push(atom("reservation.reason", body.reason));
}
return atoms;
}
if obs.kind == "booking.cancelled" {
return [
atom("booking.cancelled.request_id", body.request_id),
atom("booking.cancelled.slot_id", body.slot_id),
];
}
if obs.kind == "confirmation.result" {
let atoms = [
atom("confirmation.result", body.result),
atom("confirmation.request_id", body.request_id),
];
if body.contains("reason") {
atoms.push(atom("confirmation.reason", body.reason));
}
return atoms;
}
[]
}

The mapper is a pure function. It has no side effects, no network access, and no access to previously derived state. It sees one observation at a time and returns atoms.

ontology/rules.dh is where the booking logic lives. Rules derive higher-level facts from atoms and other facts.

The first rules project atoms into typed relations:

rule booking_request(req, email, slot) :-
atom(obs, "booking.request_id", req),
atom(obs, "booking.email", email),
atom(obs, "booking.slot_id", slot).
rule slot_listed(slot) :-
atom(obs, "slot.id", slot),
atom(obs, "slot.state", "listed").

atom() is the built-in base relation that bridges observations into the logic layer. Every derived fact traces back to specific atoms, and every atom traces back to a specific observation.

Slot availability is derived, not stored. A slot is available when it is listed, has no active hold, and is not already confirmed:

rule slot_available(slot) :-
slot_listed(slot),
not slot_hold_active(_, slot),
not booking_confirmed(_, slot).

Reservation results from the clinic API produce assertions and retractions:

rule assert slot_hold_active(req, slot) :-
atom(obs, "reservation.result", "succeeded"),
atom(obs, "reservation.request_id", req),
atom(obs, "reservation.slot_id", slot).
rule assert booking_rejected(req, slot, reason) :-
atom(obs, "reservation.result", "failed"),
atom(obs, "reservation.request_id", req),
atom(obs, "reservation.slot_id", slot),
atom(obs, "reservation.reason", reason).

Cancellation retracts the hold, making the slot available again:

rule assert booking_cancelled(req, slot) :-
atom(obs, "booking.cancelled.request_id", req),
atom(obs, "booking.cancelled.slot_id", slot).
rule retract slot_hold_active(req, slot) :-
booking_cancelled(req, slot).

Confirmation is pending when a request has an active hold. It gets retracted when the confirmation succeeds or fails:

rule assert confirmation_pending(req, email, slot) :-
booking_request(req, email, slot),
slot_hold_active(req, slot).
rule assert confirmation_sent(req) :-
atom(obs, "confirmation.result", "sent"),
atom(obs, "confirmation.request_id", req).
rule retract confirmation_pending(req, email, slot) :-
booking_request(req, email, slot),
slot_hold_active(req, slot),
confirmation_sent(req).
rule assert booking_confirmed(req, slot) :-
confirmation_sent(req),
slot_hold_active(req, slot).

Every request eventually reaches a terminal state. The booking_status relation computes the current lifecycle position:

rule booking_terminal(req) :- booking_confirmed(req, _).
rule booking_terminal(req) :- booking_rejected(req, _, _).
rule booking_terminal(req) :- booking_cancelled(req, _).
rule booking_status(req, "requested") :-
booking_request(req, _, _),
not slot_hold_active(req, _),
not booking_terminal(req).
rule booking_status(req, "reserved") :-
slot_hold_active(req, _),
not confirmation_sent(req),
not confirmation_failed(req, _),
not booking_cancelled(req, _).
rule booking_status(req, "confirmed") :-
booking_confirmed(req, _).
rule booking_status(req, "rejected") :-
booking_rejected(req, _, _).
rule booking_status(req, "cancelled") :-
booking_cancelled(req, _).

Three invariants enforce structural correctness across all evaluation states:

invariant no_double_hold(slot) :-
count slot_hold_active(_, slot) <= 1.
invariant no_double_booking(slot) :-
count booking_confirmed(_, slot) <= 1.
invariant one_terminal_outcome(req) :-
count booking_terminal(req) <= 1.

These are not tests. They are constraints checked after every fixed point. If any invariant is violated, the evaluator halts.

ontology/intents.dh derives outbound actions from stable state. Intents only fire when preconditions are met:

rule intent.reserve_slot(req, slot) :-
booking_request(req, _, slot),
slot_available(slot),
not slot_hold_active(req, slot),
not booking_terminal(req).
rule intent.send_confirmation(req, email, slot) :-
confirmation_pending(req, email, slot),
not confirmation_sent(req),
not confirmation_failed(req, _),
not booking_cancelled(req, _).

intent.reserve_slot fires when a booking request exists for an available slot that doesn’t already have a hold. intent.send_confirmation fires when confirmation is pending and hasn’t been sent or cancelled. The shell executes these intents against the declared capabilities in jacqos.toml, and the results come back as new observations.

Fixtures define deterministic scenarios and their expected world state. Each fixture is a JSONL file where every line is an observation.

A patient requests a slot, the clinic reserves it, and the confirmation email is sent:

{"kind":"slot.status","payload":{"slot_id":"slot-42","state":"listed"}}
{"kind":"booking.request","payload":{"request_id":"req-1","email":"pat@example.com","slot_id":"slot-42"}}
{"kind":"reservation.result","payload":{"result":"succeeded","request_id":"req-1","slot_id":"slot-42"}}
{"kind":"confirmation.result","payload":{"result":"sent","request_id":"req-1"}}

The expected world state after replay includes booking_confirmed("req-1", "slot-42"), booking_status("req-1", "confirmed"), and booking_terminal("req-1"). The confirmation_pending fact appears in contradictions because it was asserted when the hold was placed and retracted when the confirmation was sent.

A patient cancels after the reservation succeeds but before confirmation:

{"kind":"slot.status","payload":{"slot_id":"slot-42","state":"listed"}}
{"kind":"booking.request","payload":{"request_id":"req-2","email":"cancelled@example.com","slot_id":"slot-42"}}
{"kind":"reservation.result","payload":{"result":"succeeded","request_id":"req-2","slot_id":"slot-42"}}
{"kind":"booking.cancelled","payload":{"request_id":"req-2","slot_id":"slot-42"}}

After replay, booking_cancelled("req-2", "slot-42") is asserted and slot_hold_active is retracted — the slot becomes available again. The booking_status is "cancelled" and the request is terminal.

Two patients request the same slot. The first reservation succeeds; the second is rejected:

{"kind":"slot.status","payload":{"slot_id":"slot-42","state":"listed"}}
{"kind":"booking.request","payload":{"request_id":"req-1","email":"alice@example.com","slot_id":"slot-42"}}
{"kind":"booking.request","payload":{"request_id":"req-2","email":"bob@example.com","slot_id":"slot-42"}}
{"kind":"reservation.result","payload":{"result":"succeeded","request_id":"req-1","slot_id":"slot-42"}}
{"kind":"reservation.result","payload":{"result":"failed","request_id":"req-2","slot_id":"slot-42","reason":"slot already held"}}

After replay, req-1 has status "reserved" with an active hold and pending confirmation. req-2 is "rejected" and terminal. The no_double_hold and no_double_booking invariants hold because only one request has an active hold.

Run all fixtures and invariants:

Terminal window
$ jacqos verify
Replaying fixtures...
happy-path.jsonl PASS (4 observations, 7 facts)
contradiction-path.jsonl PASS (4 observations, 7 facts)
double-booking-path.jsonl PASS (5 observations, 8 facts)
Checking invariants...
no_double_hold PASS
no_double_booking PASS
one_terminal_outcome PASS
All checks passed.

Every fixture replays from scratch on a clean database. The output is deterministic — same observations, same evaluator, same facts every time.

Checkpoint B for this example exercises the full public surface on a clean database: replay, verify, lineage fork, Studio comparison, exports, and stats.

Terminal window
# Start clean
rm -rf .jacqos
# Replay the main lineage
jacqos replay fixtures/happy-path.jsonl
jacqos replay fixtures/contradiction-path.jsonl
jacqos verify
# Fork a child lineage from the committed parent head
jacqos lineage fork
# Example: replay the rejected second booking into the child branch only
jacqos replay --lineage <CHILD_LINEAGE_ID> fixtures/double-booking-path.jsonl
# Open Studio on the child; the Compare lens chip pins the parent as the comparison evaluator
jacqos studio --lineage <CHILD_LINEAGE_ID>
# Export proof artifacts and graph views
jacqos export verification-bundle
jacqos export graph-bundle
# Inspect storage and namespace-reduct partitions
jacqos stats

What you should see:

  • jacqos verify reports the shadow evaluator conformance summary and the rule-shape lint summary.
  • jacqos studio --lineage <CHILD_LINEAGE_ID> opens Studio on the child worldview. Provenance for the rejected attempt is available in the drill inspector and timeline. The Compare lens chip pins the parent-vs-child comparison evaluator (full dual-pane render in V1.1). The Ontology destination groups relations by stratum and prefix; per-relation rule-shape and namespace-partition visuals ship with the V1.1 rule-graph surface — until then the same summaries live in generated/verification/.
  • jacqos export verification-bundle and jacqos export graph-bundle write fresh artifacts under generated/.
  • jacqos stats reports namespace-reduct partitions in JSON.
  • The parent lineage remains unchanged after the child replay. There is no merge-back path.

Here is the complete flow for the happy path:

Observation: slot.status {slot_id: "slot-42", state: "listed"}
-> Atoms: slot.id="slot-42", slot.state="listed"
-> Fact: slot_listed("slot-42")
-> Fact: slot_available("slot-42")
Observation: booking.request {request_id: "req-1", email: "pat@example.com", slot_id: "slot-42"}
-> Atoms: booking.request_id="req-1", booking.email="pat@example.com", booking.slot_id="slot-42"
-> Fact: booking_request("req-1", "pat@example.com", "slot-42")
-> Intent: intent.reserve_slot("req-1", "slot-42") [slot is available, no hold yet]
Observation: reservation.result {result: "succeeded", request_id: "req-1", slot_id: "slot-42"}
-> Atoms: reservation.result="succeeded", reservation.request_id="req-1", reservation.slot_id="slot-42"
-> Assert: slot_hold_active("req-1", "slot-42")
-> Retract: slot_available("slot-42") [slot now has a hold]
-> Assert: confirmation_pending("req-1", "pat@example.com", "slot-42")
-> Intent: intent.send_confirmation("req-1", "pat@example.com", "slot-42")
Observation: confirmation.result {result: "sent", request_id: "req-1"}
-> Atoms: confirmation.result="sent", confirmation.request_id="req-1"
-> Assert: confirmation_sent("req-1")
-> Assert: booking_confirmed("req-1", "slot-42")
-> Retract: confirmation_pending("req-1", "pat@example.com", "slot-42")
-> Fact: booking_terminal("req-1")
-> Fact: booking_status("req-1", "confirmed")

Every fact traces back to specific observations. Open Studio to follow any provenance edge visually.

Assert and retract model state changes explicitly. When a cancellation arrives, slot_hold_active is retracted and appears in contradictions. This is not an error — it is the system recording that a fact was believed and then revoked.

Intents derive from stable state, not events. intent.reserve_slot fires because the world state says a slot is available and a request exists, not because a “booking requested” event was received. If the slot becomes unavailable before the intent executes, it won’t re-derive.

Invariants are universal, fixtures are specific. The no_double_booking invariant holds across all evaluation states. The happy-path fixture proves one specific scenario produces the right output. You need both.