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.
Why this matters
Section titled “Why this matters”Traditional agent loops blur three different things together:
- the model’s suggestion,
- the system’s actual decision,
- 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.
Descriptive vs action proposals
Section titled “Descriptive vs action proposals”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 four surfaces
Section titled “The four surfaces”The action-proposal pattern uses four namespaces that you should keep mentally distinct.
1. candidate.* — descriptive relay
Section titled “1. candidate.* — descriptive relay”Reserved for descriptive model output. Not authority by itself; ratified by ontology rules into trusted domain facts. See the fallible sensors guide for details.
2. proposal.* — action relay
Section titled “2. proposal.* — action relay”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.
3. Accepted domain decision facts
Section titled “3. Accepted domain decision facts”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.
4. intent.* — executable surface
Section titled “4. intent.* — executable surface”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.
Authoring a proposal-relay mapper
Section titled “Authoring a proposal-relay mapper”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), ]; } []}Authoring the ratification rules
Section titled “Authoring the ratification rules”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.
Load-time enforcement
Section titled “Load-time enforcement”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.
The llm.complete capability
Section titled “The llm.complete capability”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.
Success and failure observations
Section titled “Success and failure observations”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_failedllm.malformed_outputllm.refusalllm.error
These are ordinary observations. Your mapper and ontology can react to them like any other evidence surface.
End-to-end pattern
Section titled “End-to-end pattern”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:
| Code | Meaning |
|---|---|
E2401 | A 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. |
E2103 | Duplicate relation declaration — typically caused by re-declaring a proposal.* relation in two .dh files. |
E2004 | A relation referenced by a rule body or aggregate is not declared anywhere. |
E2005 | Arity mismatch on a proposal.* (or any) relation atom. |
E2501 | An 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.
Why no reserved plan.*
Section titled “Why no reserved plan.*”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_offerrefund.decision.approved_refundsecurity.decision.rotate_key
This preserves product clarity and keeps Studio views readable.
Going deeper
Section titled “Going deeper”- LLM Decision Containment pattern — the high-level pattern this guide implements.
- Fallible Sensors guide — the
descriptive sibling of this pattern, using
candidate.*instead ofproposal.*. .dhlanguage reference — full grammar, operator semantics, and the complete validator diagnostic inventory.jacqos.tomlreference — capability and resource schema forllm.completebindings.- Effects and intents guide — what
happens after
intent.*fires.
Next steps
Section titled “Next steps”Scaffold a decision-pattern app and try the pattern end-to-end:
jacqos scaffold my-decision-app --pattern decisioncd my-decision-appjacqos devjacqos replay fixtures/happy-path.jsonljacqos verifyThen open Studio to follow a proposal through ratification:
jacqos studioStudio surfaces the proposal.* relay, the accepted decision facts, and
the resulting intents in a single timeline so you can audit the
containment visually.