Skip to content

Chevy Offer Containment Walkthrough

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:

  1. Observations arrive as JSON events (customer.inquiry, inventory.vehicle_snapshot, dealer.pricing_policy_snapshot, llm.offer_decision_result, sales.offer_sent, sales.review_opened)
  2. Mappers extract both trusted structural atoms and requires_relay semantic atoms from the same LLM-decision event
  3. Rules derive proposal.*, current decision state, policy-driven decisions, executed offers, and request status
  4. Invariants enforce that no offer is sent below the auto-authorize floor and that every executed effect traces back to a matching decision
  5. Intents derive outbound offer submission only from authorized decision state
  6. Fixtures prove the happy path, the contained $1 offer, the manager-review escalation, the correction-turn contradiction, and an unsafe operator override
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 artifacts

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 = false
blob_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.

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.

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, and offer_decision.seq are trusted structure
  • offer_decision.action and offer_decision.price_usd are 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.

This example ships six fixtures:

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.

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.

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.

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.

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.

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.

Open the demo with jacqos studio --lineage chevy-offer-containment and the bundled demo-path fixture loads automatically.

  • Sent offer -> the Done tab shows a row like offer-1: $29,500 offer to Equinox, policy auto-authorized. Drill in and the inspector takes you from the executed offer back through sales.decision.authorized_offer, the policy floor fact, and the model’s offer-decision observation.
  • Blocked $1 offer -> the Blocked tab shows a row like offer-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 Waiting tab 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.

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.

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_primary hold 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:

Terminal window
jacqos scaffold --pattern decision my-decision-app