Skip to content

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.

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.

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.

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").

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.

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.

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.

Terminal window
jacqos verify
Replaying 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.

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.

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 reserved proposal.* 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.

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.