Skip to content

Action Proposals

JacqOS does not make your model infallible. It makes bad model decisions containable.

When a model suggests what to do next, that suggestion should not be treated as an executable action. It should be treated as a proposal that the ontology must authorize before anything touches the world.

That is the core pattern:

proposal.* -> accepted domain decision -> intent.*
  • proposal.* captures what the model suggests doing.
  • Accepted domain decision facts capture what your system authorizes.
  • intent.* is the point where JacqOS may now attempt an external effect.

This is how JacqOS replaces an opaque agent loop with an auditable one. The model proposes. The ontology authorizes. The runtime executes.

This page is the canonical reference for the action-proposal pattern. It covers what the pattern is, why it exists, how to author the mapper and ratification rules, what the loader enforces, and which validator diagnostics you will see when you get it wrong.

Traditional agent loops blur three different things together:

  1. the model’s suggestion,
  2. the system’s actual decision,
  3. the external action that follows.

That makes failure hard to inspect. When something bad happens, you cannot tell whether the model proposed the wrong thing, the policy failed to block it, or the runtime executed something it never should have.

JacqOS keeps those layers separate:

  • bad proposals stay visible for audit;
  • accepted decisions are explicit facts;
  • executable intents are derived only from authorized state.

This is especially important for business, policy, and safety decisions — pricing and discounting, refunds and credits, account changes, infrastructure actions, escalation and approval flows.

JacqOS distinguishes two kinds of non-authoritative model output.

  • candidate.* for “what may be true.”
  • proposal.* for “what the model suggests doing.”

Use candidate.* when the model is describing the world — extracting symptoms, parsing speech, classifying documents.

Use proposal.* when the model is suggesting an action — send this refund, offer this price, isolate this service, escalate this case.

That distinction keeps epistemic uncertainty separate from policy uncertainty. The fallible-sensor pattern uses candidate.*; this guide focuses on proposal.*.

The action-proposal pattern uses four namespaces that you should keep mentally distinct.

Reserved for descriptive model output. Not authority by itself; ratified by ontology rules into trusted domain facts. See the fallible sensors guide for details.

Reserved for action or plan suggestions from a fallible decider (typically an LLM). Examples: proposal.offer_action, proposal.refund_action, proposal.remediation_action. These tuples are not execution authority. They are the action-side trust boundary.

The layer where your ontology records what it actually authorizes. Examples: sales.decision.authorized_offer, refund.decision.approved_refund, remediation.plan.

This layer is intentionally domain-specific. JacqOS does not reserve a generic plan.* namespace. The missing universal primitive was proposal.*, not an all-purpose accepted-decision namespace. Keeping the accepted-decision layer in your domain vocabulary preserves audit clarity and makes Studio views readable.

intent.* is the executable effect surface. If a tuple appears under intent.*, the runtime may attempt the external action subject to the capability bindings declared in jacqos.toml.

The recommended action flow is:

proposal.* -> accepted domain decision -> intent.*

The collapsed form is still legal for simple apps:

proposal.* -> intent.*

But the explicit middle layer is the recommended pattern when policy is part of the story. It gives you:

  • a clear audit trail from proposal to authorization,
  • one place to encode policy,
  • support for blocked and review-required outcomes,
  • support for multiple downstream effects from one accepted decision.

Mappers declare a relay contract so the loader knows which observation-class atoms must funnel through proposal.* before becoming trusted. Without that declaration, an atom from a fallible decider could flow straight into a domain rule, defeating the containment.

fn mapper_contract() {
#{
requires_relay: [
#{
observation_class: "llm.offer_decision_result",
predicate_prefixes: ["offer_decision."],
relay_namespace: "proposal",
}
],
}
}

This says: observations of class llm.offer_decision_result whose atoms use the offer_decision. predicate prefix must first relay through proposal.*.

For descriptive output, the relay namespace is candidate instead. The contract shape is identical; only the namespace changes.

The mapper itself is ordinary Rhai. It emits atoms whose names begin with the declared predicate prefix.

fn map_observation(obs) {
let body = parse_json(obs.payload);
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),
];
}
[]
}

The ontology converts those atoms into a proposal.* tuple, then layers a domain decision relation on top, then derives intents only from the accepted decision.

-- Lift the decider atoms into the proposal relay.
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).
-- Authorize, require review, or block — the policy lives here.
rule sales.decision.authorized_offer(request_id, vehicle_id, price_usd) :-
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, "below_manager_review_floor") :-
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),
price_usd > 0,
price_usd < manager_floor_usd.
-- Only an authorized decision derives the executable intent.
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).

Note the operator discipline: = only inside binding constructs (e.g. seq = max ...), == / != / < / <= / > / >= for comparison inside rule bodies. The .dh validator enforces this; see the .dh language reference for the full grammar.

The loader enforces the relay boundary before the app runs. If a rule derives an accepted fact or executable intent from requires_relay-marked atoms without first passing through the required reserved namespace, the program is rejected at load time.

This means JacqOS enforces two first-class relay shapes:

  • descriptive atoms must first hit candidate.*;
  • action atoms must first hit proposal.*.

The check is keyed on mapper predicate configuration, not on observation class strings — once you declare the contract, every rule that touches the affected atoms is audited.

llm.complete is the single built-in model-call capability. It is the transport and replay surface for calling a model — not the semantic meaning of the result.

An llm.complete intent binding declares the bound model resource and the result observation kind. The bindings live in jacqos.toml under the table-of-tables [capabilities.intents] (not [[intents]] — that shape does not exist).

[capabilities.intents]
"intent.request_offer_decision" = { capability = "llm.complete", resource = "sales_decision_model", result_kind = "llm.offer_decision_result" }
[resources.model.sales_decision_model]
provider = "openai"
model = "gpt-4o-mini"
credential_ref = "OPENAI_API_KEY"
schema = "schemas/offer-decision.json"
replay = "record"

This keeps three concerns separate:

  • binding chooses which model resource to call;
  • resource chooses provider, provider model, auth, schema, and replay policy;
  • result_kind chooses which observation kind comes back on success.

For the full TOML schema and stability tags, see the jacqos.toml reference.

On success, llm.complete appends the configured result observation kind (e.g. llm.offer_decision_result). The runtime also records an LLM capture envelope with request metadata, the provider response body, the parsed payload when present, validation status, and the appended outcome observation.

On failure paths, the runtime appends standardized observation kinds:

  • llm.schema_validation_failed
  • llm.malformed_output
  • llm.refusal
  • llm.error

These are ordinary observations. Your mapper and ontology can react to them like any other evidence surface.

The Chevy offer-containment example threads the whole shape together:

customer.inquiry
-> intent.request_offer_decision
-> llm.complete
-> llm.offer_decision_result (or llm.schema_validation_failed, etc.)
-> proposal.offer_action / proposal.offer_price
-> sales.decision.authorized_offer | sales.decision.requires_manager_review | sales.decision.blocked_offer
-> intent.send_offer | intent.open_manager_review | (no intent on block)

The decision rules stay simple and explicit:

  • price above auto floor → sales.decision.authorized_offer;
  • price between review and auto floor → sales.decision.requires_manager_review;
  • price below review floor → sales.decision.blocked_offer.

That is the promise JacqOS makes: the model can still suggest a terrible action, but the terrible action does not become reality.

Validator diagnostics for proposal violations

Section titled “Validator diagnostics for proposal violations”

When the relay boundary is violated, the validator emits a stable diagnostic code. The full diagnostic inventory lives in the .dh language reference; the codes you will most often see while authoring proposals are:

CodeMeaning
E2401A rule derives from requires_relay atoms without going through the declared relay namespace, or an executable intent.* depends directly on proposal.* without a domain decision relation.
E2103Duplicate relation declaration — typically caused by re-declaring a proposal.* relation in two .dh files.
E2004A relation referenced by a rule body or aggregate is not declared anywhere.
E2005Arity mismatch on a proposal.* (or any) relation atom.
E2501An unstratified negation cycle was introduced — e.g. a domain decision negates over its own input proposal.

E2401 is the diagnostic that specifically guards proposal containment. If you see it, the fix is almost always to add an intermediate proposal.* rule that lifts the atoms into the relay, then a domain decision relation that ratifies the proposal before any intent.* rule consumes it. Direct proposal.* -> intent.* shortcuts are rejected even for small apps.

Accepted decisions belong to the domain. proposal.* is the universal primitive because every app needs a standard way to represent non-authoritative action suggestions. The accepted decision layer is more meaningful when it stays specific:

  • sales.decision.authorized_offer
  • refund.decision.approved_refund
  • security.decision.rotate_key

This preserves product clarity and keeps Studio views readable.

Scaffold a decision-pattern app and try the pattern end-to-end:

Terminal window
jacqos scaffold my-decision-app --pattern decision
cd my-decision-app
jacqos dev
jacqos replay fixtures/happy-path.jsonl
jacqos verify

Then open Studio to follow a proposal through ratification:

Terminal window
jacqos studio

Studio surfaces the proposal.* relay, the accepted decision facts, and the resulting intents in a single timeline so you can audit the containment visually.