Now Wire In A Containment Pattern
You finished Build Your First App with a
booking lifecycle, two named invariants, and a green
jacqos verify. That app does not yet exercise either of the
flagship containment patterns. This page is the rung-6 walkthrough
that wires one in: an LLM-decider proposes which appointment slot
fits a patient’s stated preference, and the ontology decides
whether to ratify the proposal before any reservation intent fires.
Use this page after first-app, with the same scaffold checked out. Roughly thirty minutes. The result is a working LLM Decision Containment pattern grafted onto a real domain.
Why A Decider, Not An Orchestrator
Section titled “Why A Decider, Not An Orchestrator”A naive integration would let the LLM call the booking API directly: “given the patient’s preferences, pick a slot and reserve it.” That is the failure shape that produces $1 Tahoes and invented refund policies — there is nothing between the model’s output and the effect.
The decision-containment pattern inverts the flow. The model never
calls the booking API. The model writes one structured observation
into the system: “I propose slot X for request Y.” That observation
becomes a proposal.* fact. Only an explicit ontology rule —
written by you, inspectable in .dh, cited in provenance — can
turn that proposal into an executable intent.reserve_slot.
The model is free to suggest anything. The platform refuses to act on a suggestion the ontology cannot ratify.
Step 1: Declare The Proposal And Decision Relations
Section titled “Step 1: Declare The Proposal And Decision Relations”Open ontology/schema.dh and add the relations the pattern
introduces. The proposal.* namespace is the only legal landing
place for fallible-decider output.
relation patient_preference(request_id: text, urgency: text)relation proposal.slot_choice(request_id: text, slot_id: text, decision_seq: int)relation booking.current_decision_seq(request_id: text, decision_seq: int)relation booking.decision.authorized_slot(request_id: text, slot_id: text)relation booking.decision.blocked_slot(request_id: text, slot_id: text, reason: text)relation booking.decision.requires_human_review(request_id: text, slot_id: text, reason: text)The decision-relation triplet — authorized_*, blocked_*,
requires_human_review — is the policy surface. Anything the model
proposes lands in exactly one of those buckets.
Step 2: Mark The Mapper Predicates As Relay-Required
Section titled “Step 2: Mark The Mapper Predicates As Relay-Required”Add a mapper_contract() to mappings/inbound.rhai. This is what
flips the loader’s relay-boundary check on for the model’s output:
fn mapper_contract() { #{ requires_relay: [ #{ observation_class: "llm.slot_decision_result", predicate_prefixes: [ "slot_decision.request_id", "slot_decision.slot_id", "slot_decision.seq", ], relay_namespace: "proposal", } ], }}Then add the mapper branch that produces those atoms when the decider posts a result:
if obs.kind == "llm.slot_decision_result" { let body = parse_json(obs.payload); return [ atom("slot_decision.request_id", body.request_id), atom("slot_decision.slot_id", body.slot_id), atom("slot_decision.seq", body.seq), ];}
if obs.kind == "patient.preference" { let body = parse_json(obs.payload); return [ atom("preference.request_id", body.request_id), atom("preference.urgency", body.urgency), ];}Once requires_relay is set, the loader will reject any rule that
derives a non-proposal.* fact directly from those atoms.
Step 3: Stage The Proposal
Section titled “Step 3: Stage The Proposal”Add the staging rules that lift the relay-marked atoms into the
proposal.* namespace. This is the only legal bridge:
rule patient_preference(request_id, urgency) :- atom(obs, "preference.request_id", request_id), atom(obs, "preference.urgency", urgency).
rule assert proposal.slot_choice(request_id, slot_id, seq) :- atom(obs, "slot_decision.request_id", request_id), atom(obs, "slot_decision.slot_id", slot_id), atom(obs, "slot_decision.seq", seq).
rule booking.current_decision_seq(request_id, seq) :- proposal.slot_choice(request_id, _, _), seq = max proposal.slot_choice(request_id, _, s), s.The decision-sequence helper is what lets the decider revise itself —
a higher-seq observation supersedes earlier proposals without
mutation.
Step 4: Author The Decision Rules
Section titled “Step 4: Author The Decision Rules”The decision rules are the policy you intend to publish. Every proposal lands in exactly one bucket:
-- Authorize the model's choice when the slot is genuinely available.rule booking.decision.authorized_slot(request, slot) :- booking.current_decision_seq(request, seq), proposal.slot_choice(request, slot, seq), slot_available(slot).
-- Block the proposal when the slot is already held or confirmed.rule booking.decision.blocked_slot(request, slot, "slot_unavailable") :- booking.current_decision_seq(request, seq), proposal.slot_choice(request, slot, seq), not slot_available(slot).
-- Escalate to a human when the patient marked the request urgent.-- Authorized + urgent both fire; the intent rule below resolves the-- precedence by withholding the auto-reserve when review is required.rule booking.decision.requires_human_review(request, slot, "urgent_patient") :- booking.current_decision_seq(request, seq), proposal.slot_choice(request, slot, seq), patient_preference(request, "urgent").Step 5: Gate The Intent
Section titled “Step 5: Gate The Intent”Replace the intent.reserve_slot rule from rung 5 with one that
fires only on a ratified decision:
rule intent.reserve_slot(req, slot) :- booking.decision.authorized_slot(req, slot), not booking.decision.requires_human_review(req, slot, _), not slot_hold_active(req, slot).This is the structural safety boundary. There is no way for the
model’s output to derive intent.reserve_slot except through
booking.decision.authorized_slot, which is your code, in your
ontology, with full provenance.
Step 6: Add The Backstop Invariant
Section titled “Step 6: Add The Backstop Invariant”Decision rules can have bugs. The named invariant is the independent second net:
relation booking.violation.reservation_without_authorization(request_id: text, slot_id: text)
rule booking.violation.reservation_without_authorization(req, slot) :- slot_hold_active(req, slot), not booking.decision.authorized_slot(req, slot).
invariant reservation_requires_authorization() :- count booking.violation.reservation_without_authorization(_, _) <= 0.If somebody (the model, a refactor, an upstream system) ever causes a hold to land without an authorized decision, the invariant names the violator and refuses the transition.
Step 7: Write The Two Fixtures
Section titled “Step 7: Write The Two Fixtures”Add two fixtures to your fixtures/ directory. The first proves
authorization fires on a sane proposal. The second proves
containment: when the model proposes a slot that is not available,
the intent never derives.
fixtures/decider-authorized-path.jsonl:
{"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":"patient.preference","payload":{"request_id":"req-1","urgency":"routine"}}{"kind":"llm.slot_decision_result","payload":{"request_id":"req-1","slot_id":"slot-42","seq":1}}fixtures/decider-blocked-path.jsonl:
{"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":"booking.request","payload":{"request_id":"req-2","email":"sam@example.com","slot_id":"slot-42"}}{"kind":"patient.preference","payload":{"request_id":"req-2","urgency":"routine"}}{"kind":"llm.slot_decision_result","payload":{"request_id":"req-2","slot_id":"slot-42","seq":1}}Each gets an accompanying *.expected.json declaring the expected
facts and intents. The blocked fixture’s expectation includes
booking.decision.blocked_slot(req-2, slot-42, "slot_unavailable")
and explicitly asserts no intent.reserve_slot(req-2, _). That is
the containment proof.
Step 8: Verify The Containment
Section titled “Step 8: Verify The Containment”jacqos verifyReplaying fixtures... decider-authorized-path.jsonl PASS (4 observations, 7 facts matched) decider-blocked-path.jsonl PASS (6 observations, 9 facts matched) happy-path.jsonl PASS (4 observations, 6 facts matched)
Checking invariants... no_double_hold PASS no_double_booking PASS reservation_requires_authorization PASS
All checks passed.To convince yourself the relay boundary is real, try writing a
rule that derives intent.reserve_slot directly from
atom(obs, "slot_decision.slot_id", slot) and reload. The loader
rejects the program at parse time before any fixture even runs.
Step 9: Try An Adversarial Proposal
Section titled “Step 9: Try An Adversarial Proposal”The point of decision containment is that the model can suggest
anything and the world stays safe. Add one more fixture that
demonstrates this — an llm.slot_decision_result that names a slot
the system has never heard of:
{"kind":"booking.request","payload":{"request_id":"req-3","email":"x@example.com","slot_id":"slot-42"}}{"kind":"llm.slot_decision_result","payload":{"request_id":"req-3","slot_id":"slot-DOES-NOT-EXIST","seq":1}}Run jacqos verify again. The proposal lands as a fact under
proposal.slot_choice, the decision rule produces
booking.decision.blocked_slot(req-3, slot-DOES-NOT-EXIST, "slot_unavailable"), and no intent.reserve_slot derives. The
hallucinated slot ID never reaches the booking API.
What Just Happened
Section titled “What Just Happened”You took the rung-5 booking app and wrapped its reserve-intent behind the LLM-decision-containment pattern. Concretely:
- The model’s output is now a first-class
observation (
llm.slot_decision_result) that lands in the reservedproposal.*namespace, not a free-form action. - The relay-boundary loader check refuses any rule that would bypass the namespace.
- Three explicit decision rules (authorize, block, escalate) are the only legal path from a proposal to an executable intent.
- A named invariant
(
reservation_requires_authorization) is the independent backstop against the decision rules themselves having bugs. - Two fixtures plus an adversarial fixture prove the pattern with digest-backed evidence.
The same shape works for any AI-proposed action. Refund decisions, incident-remediation steps, procurement orders, customer-service escalations — the proposal-then-decide pipeline is identical.
What To Read Next
Section titled “What To Read Next”You are at rung 6. The natural next step is multi-agent.
- Compose Multiple Agents — rung 7. Add a triage agent that proposes which clinic should handle the booking, and watch shared-reality coordination produce a deterministic result without a workflow graph.
- LLM Decision Containment — the pattern reference, with the full Chevy walkthrough as the worked example.
- Action Proposals — deep
authoring guide for
proposal.*and ratification rules.
If you want to understand why this composes — why two agents writing into the same shared model never deadlock or drift — head to Model-Theoretic Foundations.