Chevy Offer Containment Walkthrough
What You’ll Build
Section titled “What You’ll Build”A dealership chatbot where an LLM proposes commercial offers (price, action) for a customer inquiry, the platform keeps those proposals behind proposal. relations, and only authorized decisions reach the sales API. The system enforces that absurd offers such as “send the Tahoe for $1” stay in a blocked or escalated state instead of becoming trusted decisions or downstream actions.
This walkthrough is the cleanest demonstration of the proposal -> decision -> intent pattern:
llm.offer_decision_result -> proposal.offer_* -> sales.decision.* -> intent.*It covers the full JacqOS pipeline with a focus on fallible-decider boundaries:
- Observations arrive as JSON events (
customer.inquiry,inventory.vehicle_snapshot,dealer.pricing_policy_snapshot,llm.offer_decision_result,sales.offer_sent,sales.review_opened) - Mappers extract both trusted structural atoms and
requires_relaysemantic atoms from the same LLM-decision event - Rules derive
proposal.*, current decision state, policy-driven decisions, executed offers, and request status - Invariants enforce that no offer is sent below the auto-authorize floor and that every executed effect traces back to a matching decision
- Intents derive outbound offer submission only from authorized decision state
- Fixtures prove the happy path, the contained
$1offer, the manager-review escalation, the correction-turn contradiction, and an unsafe operator override
Project Structure
Section titled “Project Structure”jacqos-chevy-offer-containment/ jacqos.toml ontology/ schema.dh # relation declarations rules.dh # proposal staging, decision rules, invariants intents.dh # offer-decision and offer-send intent derivation mappings/ inbound.rhai # mapper contract + observation mapping prompts/ offer-decision-system.md # prompt bundle for package export schemas/ offer-decision.json # structured-output schema fixtures/ happy-path.jsonl happy-path.expected.json blocked-dollar-path.jsonl blocked-dollar-path.expected.json manager-review-path.jsonl manager-review-path.expected.json contradiction-path.jsonl contradiction-path.expected.json unsafe-observation-path.jsonl unsafe-observation-path.expected.json demo-path.jsonl demo-path.expected.json generated/ ... # verification, graph, and export artifactsStep 1: Configure The App
Section titled “Step 1: Configure The App”jacqos.toml declares the app identity, the sales API and decision-model bindings, and the Studio metadata:
app_id = "jacqos-chevy-offer-containment"app_version = "0.1.0"
[paths]ontology = ["ontology/*.dh"]mappings = ["mappings/*.rhai"]prompts = ["prompts/*.md"]schemas = ["schemas/*.json"]fixtures = ["fixtures/*.jsonl"]
[capabilities]http_clients = ["sales_api"]models = ["sales_decision_model"]timers = falseblob_store = true
[capabilities.intents]"intent.request_offer_decision" = { capability = "llm.complete", resource = "sales_decision_model", result_kind = "llm.offer_decision_result" }"intent.send_offer" = { capability = "http.fetch", resource = "sales_api" }"intent.open_manager_review" = { capability = "http.fetch", resource = "sales_api" }This example does something important on purpose: JacqOS is not blindly calling a live LLM here. The decider output arrives as llm.offer_decision_result observations, which means the walkthrough starts at the observation step while still demonstrating the same containment boundary. The bundled demo runs the deterministic provider so Studio can demonstrate containment end-to-end without an API key. If you later flip the provider mode back to live, the ontology and mapper boundary stay the same.
Step 2: Declare Relations
Section titled “Step 2: Declare Relations”The schema separates structural state, fallible proposals, accepted decisions, executed effects, and intents:
relation sales.request(request_id: text, vehicle_id: text, user_text: text)relation inventory.vehicle(vehicle_id: text, model_name: text, msrp_usd: int)relation policy.auto_authorize_min_price(vehicle_id: text, price_usd: int)relation policy.manager_review_min_price(vehicle_id: text, price_usd: int)
relation proposal.offer_action(request_id: text, vehicle_id: text, action: text, decision_seq: int)relation proposal.offer_price(request_id: text, vehicle_id: text, price_usd: int, decision_seq: int)
relation sales.decision.authorized_offer(request_id: text, vehicle_id: text, price_usd: int)relation sales.decision.blocked_offer(request_id: text, vehicle_id: text, reason: text)relation sales.decision.requires_manager_review(request_id: text, vehicle_id: text, reason: text)
relation sales.offer_sent(request_id: text, vehicle_id: text, price_usd: int)relation sales.review_opened(request_id: text, vehicle_id: text, reason: text)relation sales.request_status(request_id: text, status: text)
relation intent.request_offer_decision(request_id: text, vehicle_id: text, user_text: text)relation intent.send_offer(request_id: text, vehicle_id: text, price_usd: int)relation intent.open_manager_review(request_id: text, vehicle_id: text, reason: text)The key point is that proposal.* and sales.decision.* are separate on purpose. The model can propose send_offer at $1, but until a decision rule authorizes it, that is not the system’s accepted offer.
Step 3: Map Observations To Atoms
Section titled “Step 3: Map Observations To Atoms”This example demonstrates mapper-level partial trust directly. The mapper contract marks only the LLM offer-decision predicates as requires_relay into proposal.*:
fn mapper_contract() { #{ requires_relay: [ #{ observation_class: "llm.offer_decision_result", predicate_prefixes: ["offer_decision.action", "offer_decision.price_usd"], relay_namespace: "proposal", } ], }}Then map_observation() extracts both ordinary and fallible atoms from the same LLM event:
if obs.kind == "llm.offer_decision_result" { return [ atom("offer_decision.request_id", body.request_id), atom("offer_decision.vehicle_id", body.vehicle_id), atom("offer_decision.action", body.action), atom("offer_decision.price_usd", body.price_usd), atom("offer_decision.seq", body.seq), ];}That split is the whole design:
offer_decision.request_id,offer_decision.vehicle_id, andoffer_decision.seqare trusted structureoffer_decision.actionandoffer_decision.price_usdare fallible interpretation
Step 4: Derive Proposals, Decisions, And Executed Offers
Section titled “Step 4: Derive Proposals, Decisions, And Executed Offers”The first rules lift the model output into proposal evidence:
rule assert proposal.offer_action(request_id, vehicle_id, action, decision_seq) :- atom(obs, "offer_decision.request_id", request_id), atom(obs, "offer_decision.vehicle_id", vehicle_id), atom(obs, "offer_decision.action", action), atom(obs, "offer_decision.seq", decision_seq).
rule assert proposal.offer_price(request_id, vehicle_id, price_usd, decision_seq) :- atom(obs, "offer_decision.request_id", request_id), atom(obs, "offer_decision.vehicle_id", vehicle_id), atom(obs, "offer_decision.price_usd", price_usd), atom(obs, "offer_decision.seq", decision_seq).When a later decision arrives for the same request, the old proposals retract:
rule retract proposal.offer_price(request_id, vehicle_id, price_usd, old_seq) :- atom(obs_old, "offer_decision.request_id", request_id), atom(obs_old, "offer_decision.vehicle_id", vehicle_id), atom(obs_old, "offer_decision.price_usd", price_usd), atom(obs_old, "offer_decision.seq", old_seq), atom(obs_new, "offer_decision.request_id", request_id), atom(obs_new, "offer_decision.seq", new_seq), old_seq < new_seq.That is how the contradiction path models a re-decision turn. The first proposal remains visible as contradiction history, but it no longer drives current behavior.
Decision rules sit between proposals and any external action:
rule sales.decision.authorized_offer(request_id, vehicle_id, price_usd) :- sales.current_decision_seq(request_id, decision_seq), proposal.offer_action(request_id, vehicle_id, "send_offer", decision_seq), proposal.offer_price(request_id, vehicle_id, price_usd, decision_seq), policy.auto_authorize_min_price(vehicle_id, floor_price_usd), price_usd >= floor_price_usd.
rule sales.decision.blocked_offer(request_id, vehicle_id, "non_positive_price") :- sales.current_decision_seq(request_id, decision_seq), proposal.offer_action(request_id, vehicle_id, "send_offer", decision_seq), proposal.offer_price(request_id, vehicle_id, price_usd, decision_seq), price_usd <= 0.
rule sales.decision.requires_manager_review(request_id, vehicle_id, "discount_requires_manager_review") :- sales.current_decision_seq(request_id, decision_seq), proposal.offer_action(request_id, vehicle_id, "send_offer", decision_seq), proposal.offer_price(request_id, vehicle_id, price_usd, decision_seq), policy.manager_review_min_price(vehicle_id, manager_floor_usd), policy.auto_authorize_min_price(vehicle_id, auto_floor_usd), price_usd >= manager_floor_usd, price_usd < auto_floor_usd.This means the model can suggest “send the Tahoe for $1”, but the ontology still refuses to authorize it.
A pair of named invariants enforces the boundary as a structural property — even if a future decision rule weakens, these still hold:
invariant offer_sent_above_auto_floor() :- count sales.decision.invalid_offer_sent_floor() <= 0.
invariant offer_sent_requires_authorized_decision() :- count sales.decision.offer_sent_without_authorization() <= 0.Step 5: Derive Outbound Offers Only From Authorized Decisions
Section titled “Step 5: Derive Outbound Offers Only From Authorized Decisions”The outbound effect is intentionally narrow:
rule intent.send_offer(request_id, vehicle_id, price_usd) :- sales.decision.authorized_offer(request_id, vehicle_id, price_usd), not sales.offer_sent(request_id, vehicle_id, price_usd), not sales.review_opened(request_id, _, _).
rule intent.open_manager_review(request_id, vehicle_id, reason) :- sales.decision.requires_manager_review(request_id, vehicle_id, reason), not sales.review_opened(request_id, vehicle_id, reason), not sales.offer_sent(request_id, _, _).There is no path from raw proposal.* directly to intent.send_offer. That is the safety boundary in one rule.
Step 6: Fixtures
Section titled “Step 6: Fixtures”This example ships six fixtures:
Happy path
Section titled “Happy path”The model proposes send_offer at $53,500 on a Tahoe with a $53,000 auto-authorize floor. The decision authorizes, the offer is sent successfully. Final status: submitted.
Blocked-dollar path
Section titled “Blocked-dollar path”The model proposes send_offer at $1. The system derives sales.decision.blocked_offer with reason non_positive_price, no intent.send_offer ever derives, and the request stays in blocked status.
Manager-review path
Section titled “Manager-review path”The model proposes send_offer at a price that sits between the manager-review floor and the auto-authorize floor. The system derives sales.decision.requires_manager_review and routes through intent.open_manager_review instead of sending the offer.
Contradiction path
Section titled “Contradiction path”A first decision proposes $1. A second decision arrives at a higher seq with a policy-compliant price. The earlier proposal retracts into contradiction history, the new decision authorizes, and only the corrected offer reaches the sales API.
Unsafe-observation path
Section titled “Unsafe-observation path”A sales.offer_sent observation arrives without any matching authorized decision (simulating a broken external system or operator override). The offer_sent_requires_authorized_decision invariant fires, surfacing the structural violation through Studio rather than letting it propagate silently.
Demo path
Section titled “Demo path”A combined timeline that exercises all three primary outcomes — Equinox sent, $1 Tahoe blocked, Trax routed to manager review — so Studio’s Done / Blocked / Waiting tabs all populate on first run. This is the fixture wired to studio.default_example in jacqos.toml, so opening the bundled demo lands directly on a populated workspace rather than an empty one.
What You’ll See In Studio
Section titled “What You’ll See In Studio”Open the demo with jacqos studio --lineage chevy-offer-containment and the bundled demo-path fixture loads automatically.
- Sent offer -> the
Donetab shows a row likeoffer-1: $29,500 offer to Equinox, policy auto-authorized. Drill in and the inspector takes you from the executed offer back throughsales.decision.authorized_offer, the policy floor fact, and the model’s offer-decision observation. - Blocked $1 offer -> the
Blockedtab shows a row likeoffer-2: proposed $1 — blocked, non_positive_price. Drill in and the inspector names the blocking decision rule, the missing authorized decision, and the proposal observation that tried to produce it. - Manager-review escalation -> the
Waitingtab shows the Trax proposal parked for review. Drill in and the inspector names the specific manager-review decision that would be required to promote or cancel the proposal.
At no point does JacqOS need to trust the model. The model can be as bad as you like. Containment does not depend on its quality.
Why This Example Matters
Section titled “Why This Example Matters”This is the fallible-decider pattern in its purest form:
- the proposal is visible
- the proposal is queryable
- the proposal can drive escalation or review
- the proposal cannot silently become an authorized decision
- an authorized decision alone can drive external action
That is how you stop a viral chatbot screenshot from turning into a contract dispute.
Make It Yours
Section titled “Make It Yours”The Chevy example is one shape of LLM decision containment. The same pattern fits:
- Customer service chatbots — an LLM proposes a refund; a refund-policy decision rule authorizes, escalates, or rejects.
- Incident remediation agents — an LLM proposes a remediation step; a safety decision rule ensures named invariants like
no_kill_unsynced_primaryhold before the remediation can fire. - Procurement automation — an LLM proposes a purchase; a spending-authority decision rule gates by amount and vendor tier.
- Compliance screening — an LLM proposes a disposition; a compliance decision rule checks watchlists and jurisdictional rules.
Any time you have an AI proposing a commercial or operational action, the decision containment pattern fits. To start building, scaffold a starter app with:
jacqos scaffold --pattern decision my-decision-appNext Steps
Section titled “Next Steps”- LLM Decision Containment — the pattern page that explains the
proposal.*namespace and decision-rule mechanics in depth - Action Proposals — the canonical guide to authoring decider relays, ratification rules, and the schema reference for
proposal.*validation - Drive-Thru Ordering Walkthrough — the symmetric
candidate.*containment example for fallible sensors - Observation-First Thinking — the underlying evidence-first mental model