Metadata-Version: 2.4
Name: jevlikedecisions
Version: 0.4.0
Summary: One typed decision abstraction over jev-like decision-model providers.
License-Expression: MIT
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: llm-spendguard>=0.16.30
Provides-Extra: dev
Requires-Dist: pytest>=8; extra == "dev"
Requires-Dist: pytest-timeout>=2.3; extra == "dev"
Dynamic: license-file

# jevlikedecisions

`jevlikedecisions.decide(...)` is one typed decision entrypoint over a family of provider adapters. Shared evidence is sent once, in full, and responses are checked against both the provider shape and the originating questions. Backend selection is mandatory: no call silently falls back to the deterministic `mock_jevlike` backend.

## Installation and spend-gate setup

Python 3.11 or newer is required.

Install the package and its sole runtime dependency, `llm-spendguard`, with pip:

```console
python -m pip install jevlikedecisions
```

> **Pending first release:** The package is not yet published on PyPI. Until the first release is published, clone this repository, change into the clone, and run `python -m pip install .`. Delete this note when publishing the first release.

Before making a model call, arm the spend gate in the virtual environment. This is required: `jevlikedecisions` deliberately refuses to call a model from an interpreter where spendguard is not enforcing. With the target virtual environment activated, run:

```console
spendguard install-hook --venv "$VIRTUAL_ENV" --no-pkg
```

> **Temporary upstream workaround:** `--no-pkg` avoids a `FileNotFoundError` for pip-installed, non-editable `llm-spendguard` environments where `install-hook` otherwise looks for a repository `pyproject.toml`. Remove this flag and note after that upstream defect is fixed.

Verify the activated interpreter:

```console
spendguard doctor
```

The successful verdict line is `ENFORCING HERE: 🟢 YES — calls from this interpreter are gated`.

> **Temporary upstream workaround:** `spendguard doctor` may currently exit non-zero after printing that successful verdict, with `ValueError: Pricing returned no token cost`. Read the verdict line rather than the exit code; remove this note after that upstream defect is fixed.

If gate setup is skipped, `decide()` refuses model calls through `require_enforcing_spendguard`.

## Request and question schema

Every surface accepts the same JSON object:

```json
{
  "input_parts": ["complete evidence"],
  "questions": [{"name": "safe", "type": "predicate", "description": "Is it safe?"}],
  "backend": "mock_jevlike",
  "intent": "documented-example",
  "prompt_cache_key": null
}
```

`intent` and `backend` are required. Every decision result identifies `backend`, `provider`, `model`, `route`, `via`, `ledger_id`, `usage`, `cost_complete`, and `known_input_cost_floor_usd`. Inspect `route` to distinguish in-process and metered results. `cost_complete` says whether the cost is fully settled; when a provider omits output-token usage, a metered result has `cost_complete: false`, no ledger id, and only the known input-cost floor. In-process results are settled at zero cost. Every question has `name`, `type`, and `description`. `type` is `predicate`, `choice`, or `score`. A choice also has `choices`, whose entries contain `value` and `description`. A score also has `levels`, whose entries contain `label` and `description`. Library validation is authoritative for every surface.

### Decision suitability

`assess_decision_suitability` asks one configured classification question through `decide()` and returns the same contract through Python, CLI `suitability`, HTTP `/suitability`, and MCP `assess_decision_suitability`. Its bounded shapes are derived from the intersection of every configured backend's declared `question_types`, so a framing is backend-independent. In the shipped configuration these are `predicate`; `choice` with one or more caller-declared `{value, description}` entries; and `score` with caller-declared `{label, description}` levels within the configured minimum and maximum. A fourth configured outcome identifies prompts that require unbounded generation.

The result uses `decision_shape` for the selected question type, `required_declarations` for the schema the caller still owes, `decide_request` for a complete request that can be sent to `decide()`, and `question_type_disagreement` for a nullable object containing `classified_question_type` and `supplied_question_type`. On disagreement, the complete paid decision and suitability classification remain available, `decide_request` stays null, and `required_declarations` states what a conforming question for the classified shape requires. `out_of_scope_explanation` explains why an unsuitable prompt does not fit, and `general_llm_generation_pointer` remains present for work outside the bounded engine. The old `reframing` field is not part of this contract because an already-valid request is not a reframing, while an unsuitable prompt needs an explanation and declaration requirements rather than a fabricated request.

The optional `candidate_question` is the no-generation boundary. When supplied, it must already be a complete valid question of a type supported by every configured backend; malformed candidates are refused before `decide()` at zero spend. It is included whole in the same paid classification request and, when its type agrees with the selected shape, is returned unchanged inside `decide_request`. A post-call type disagreement is a reported finding, not a refusal. When the candidate is omitted, prose may still be classified as a bounded shape, but `decide_request` is null and `required_declarations` tells the caller what to supply. The capability never extracts choices or score levels from prose and never invents them. The zero-spend estimate accepts the same optional question, so it serializes and counts the same assessment request the paid path would submit.

Backend names and aliases come from the package configuration. An alias is accepted only at selection; estimates, approvals, receipts, audit evidence, and returned provenance always use the canonical backend name. Omitting `backend` never selects a default and produces a typed `DecisionUnavailable` refusal.

| Canonical backend | Accepted aliases | Provider | Model | Usability and precondition |
|---|---|---|---|---|
| `openai_decisions` | `openai` | `openai` | `gpt-6-luna` | Usable only with `OPENAI_API_KEY`, an enforcing spendguard interpreter, and a matching estimate approval. The sole proven call is ledger `2bed00b5f31d4858`: 319 input tokens costing `$3.19e-05`; its output side is unproven. |
| `typesafe_jev` | `jev` | `typesafe` | `jev-1.14.0` | Usable only with `TYPESAFE_API_KEY`, an enforcing spendguard interpreter, and a matching estimate approval. Input costs `$0.042` per 1M tokens and output is free, as documented at `docs.typesafe.ai/models`; the wire schema is documented at `docs.typesafe.ai/api/system-one`. No live TypeSafe call has yet been proven. |
| `mock_jevlike` | none | `mock` | `deterministic` | Usable in-process and never selected implicitly. Predicate probability is exactly `0.5`; choice selects the first option with probability/confidence `1.0` and all others `0.0`; score selects the first level with score `0.0`, probability/confidence `1.0`, and all other levels `0.0`; reported input/output tokens are both `0`. |

## Estimate, approve, then spend

The shipped configuration contains exactly three backends: metered `typesafe_jev` and `openai_decisions`, plus the in-process `mock_jevlike` test double. Their only transport kinds are `http` and `in_process`. A backend that declares the removed `subscription` transport is refused when configuration loads; subscription-lane LLM access belongs in llm-spendguard.

Choice questions may declare `"calibrated_choice_distribution": "required"` or `"calibrated_choice_distribution": "omitted"`. Omitting the declaration preserves the current `required` contract: `choice`, `probabilities` for every declared choice in order, and `confidence`. Declaring `omitted` explicitly requests only `name`, `type`, and `choice`; returning either `probabilities` or `confidence` is a named nonconformance, even if null. Unknown or malformed declarations are refused with the permitted values from the packaged `choice_distribution_contract` configuration beside `limits`. Mixed declarations in one call are supported. The shared validator enforces the declared answer fields across Python, CLI, HTTP, and MCP. Score questions are unchanged.

Self-reported confidence is **not calibrated**. In the measured eight-question probe with twenty declared choices per question, gpt-6-luna returned top probability/confidence `1.0` on every item (entropy approximately zero; effective support `1.00`), while glm-5.3 returned top probability/confidence `0.99` on every item (entropy `0.0629`; effective support `1.06`). Those degenerate vectors do not establish calibrated uncertainty. At the working shape of 32 questions × 29 choices, the measured output grew from 2,149 to 23,270 tokens. The declaration allows callers to omit that unvalidated distribution rather than pay for it. The field describes the requested distribution contract; it does not certify calibration when required.

Paid decisions fail closed. First call `decide(..., estimate_only=True)`: it counts the complete provider request, prices it through `spendguard.pricing`, spends `$0`, and persists a matching approval. Only a later identical `decide(...)` may reach the gated metered transport. Provider usage remains authoritative for the real receipt. Configure approval storage with `JEVLIKEDECISIONS_APPROVAL_DIR`, approval signing with `JEVLIKEDECISIONS_APPROVAL_INTEGRITY_KEY_FILE`, receipt storage with `JEVLIKEDECISIONS_RECEIPT_DIR`, and an alternate package configuration with `JEVLIKEDECISIONS_CONFIG`. The legacy by-value integrity variable is retained only to produce a migration refusal. The integrity file must contain a non-whitespace UTF-8 key and permit no non-owner access.

Run callers under an enforcing llm-spendguard interpreter: the gate supplies model credentials from its governed keys file before this package starts. The packaged backend `key_env` declarations currently name `OPENAI_API_KEY` for `openai_decisions` and `TYPESAFE_API_KEY` for `typesafe_jev`; jevlikedecisions has no default-path credential loader. For a repository-specific fallback only, set the environment variable named by `credential_fallback_file_env` (`JEVLIKEDECISIONS_KEYS_FILE` in the packaged configuration). If unset, its configured default is `.jevlikedecisions/jdlkeys.env` beneath the caller's home directory. An absent fallback is normal. The file may supply only names declared by backend `key_env`, never overwrites a name already supplied by the gate or caller, must permit no non-owner access, and must not have a replaceable non-sticky ancestor.

Provider requests have a socket bound. Python, CLI, HTTP, and MCP callers may override it per call with `request_timeout_seconds`; omitting it on any surface uses the positive finite `decision_request_timeout_seconds` value from decision configuration. The packaged 300-second value is a conservative ceiling, not a measured bound: historical decide rows contain no latency distribution. It is intentionally well above a plausible healthy decision and must be re-derived after this release records request latency.

The decision audit store uses SQLite WAL journaling and the configured busy timeout on every connection opened for attempts, outcomes, queries, and wire-evidence reads. WAL permits readers to continue while a writer commits; SQLite still serializes writers. An append returns `succeeded` after a durable database write, retries busy/locked contention for the configured attempt count and backoff, then atomically spools the complete row and returns `pending`; malformed data, non-contention storage errors, or spool failure return `failed`. The first append creates the store with owner-only permissions using one atomic `O_CREAT` open; later appends do not change an existing store's mode. Opening an existing rollback-journal store switches its persistent journal setting to WAL and may create `-wal` and `-shm` sidecar files. This does not rewrite the table schema or make the database incompatible, and it is reversible by setting `PRAGMA journal_mode=DELETE` after all connections have closed and the WAL has been checkpointed.

## Python, CLI, HTTP, and MCP

Python:

```python
from jevlikedecisions import decide

estimate = decide(input_parts, questions, backend=backend, intent=intent, estimate_only=True)
result = decide(
    input_parts, questions, backend=backend, intent=intent,
    request_timeout_seconds=request_timeout_seconds,
)
```

CLI input comes from a JSON file or stdin. `estimate` prints the estimate and its `approval_path`; `decide` prints answers and receipt fields. The `backend` value accepts either a canonical name or an alias reported by `backends`.

```console
jevlikedecisions backends
jevlikedecisions estimate --backend "$JEVLIKEDECISIONS_BACKEND" request.json
jevlikedecisions decide --backend "$JEVLIKEDECISIONS_BACKEND" --request-timeout-seconds "$JEVLIKEDECISIONS_REQUEST_TIMEOUT_SECONDS" request.json
```

The CLI flag fills a missing request `backend`; if both the flag and JSON field are present they must agree or the command fails. CLI exit codes follow `sysexits`: `0` (`EX_OK`) for success, `64` (`EX_USAGE`) for malformed input, `65` (`EX_DATAERR`) for a typed decision refusal, and `70` (`EX_SOFTWARE`) for an internal fault. The CLI reports every failure as `TypeName: message` on stderr; catching internal faults gives automation a stable code without hiding the fault.

The standard-library HTTP server exposes `POST /backends`, `POST /estimate`, and `POST /decide`; its `backend` field accepts the same config-derived canonical names and aliases, and `/decide` accepts the optional numeric `request_timeout_seconds` field. HTTP `400` identifies a malformed request, `422` identifies a typed decision refusal, `503` specifically identifies `DecisionUnavailable`, and `500` identifies an internal fault. Keeping unavailable decisions at `503` preserves the established retry signal, while the JSON `error` field retains the exact typed refusal. Set `JEVLIKEDECISIONS_HTTP_PORT`; optionally set `JEVLIKEDECISIONS_HTTP_HOST`. With no `JEVLIKEDECISIONS_OPERATOR_TOKEN` it binds only to loopback. When that token is set, callers must send it as an operator bearer token. A paid caller must successfully call `/estimate` before `/decide` with the identical request.
Both HTTP listeners derive their maximum request-body size from four UTF-8 bytes for every token in the largest configured backend input context. A declared body above that ceiling is refused with HTTP `413` before the body is read; the capability listener uses its normal `error`/`message` JSON shape and the MCP listener uses JSON-RPC error JSON.

```console
jevlikedecisions-http --host "$JEVLIKEDECISIONS_HTTP_HOST" --port "$JEVLIKEDECISIONS_HTTP_PORT"
```

The package's sole runtime dependency is `llm-spendguard`; its MCP surface is a standard-library JSON-RPC stdio server. The `list_decision_backends`, `estimate_decision`, and `make_decision` tools expose the same selection contract and send evidence whole. Enumeration is derived from configuration and reports canonical name, aliases, provider, model, usability, and every missing precondition; both decision tools accept exactly those canonical names and aliases. `make_decision` also exposes `estimate_mode` with the same decide semantics as the other surfaces and accepts optional numeric `request_timeout_seconds`. The approval from the estimate tool must exist before a paid call through the decision tool.

```console
jevlikedecisions-mcp
```

The same registry-derived MCP tools are also available as a long-running, JSON-only MCP
Streamable HTTP service at `MCP_HTTP_ENDPOINT_PATH` in `mcp_http_transport.py`; this is a
transport for the stdio MCP surface, not a separate capability surface. Set
`JEVLIKEDECISIONS_MCP_HTTP_PORT`; optionally set `JEVLIKEDECISIONS_MCP_HTTP_HOST`. With no
`JEVLIKEDECISIONS_MCP_HTTP_OPERATOR_TOKEN` it binds only to loopback. When that token is set,
the host is required and callers must send the token as an operator bearer token.

```console
jevlikedecisions-mcp-http --host "$JEVLIKEDECISIONS_MCP_HTTP_HOST" --port "$JEVLIKEDECISIONS_MCP_HTTP_PORT"
```

On macOS, `scripts/generate_mcp_http_launchd_agent.py` generates a launchd user-agent plist
from the repository without putting a platform-specific entry point in the wheel. The port,
approval directory, audit store path, and receipt directory are required. A wrong record
location works silently and forks the decision history, so the generator will not guess any of
these three locations. The executable defaults to `jevlikedecisions-mcp-http` beside the Python interpreter
running the generator; the owner and home come from the current environment, and the label,
plist destination, and per-tool log paths derive from them. The generated daemon environment
uses the three operator-supplied record locations and defaults only the
`approval_integrity.key` owner-only key-file path. A wrong key-file path fails loudly and closed.
The bind host defaults through the
server's existing unauthenticated loopback resolution. Every derived value has an explicit
override flag, and the plist contains only the key file path, never the key value. Generate and
load an agent like this:

```console
python scripts/generate_mcp_http_launchd_agent.py --port "$JEVLIKEDECISIONS_MCP_HTTP_PORT" \
  --approval-directory "$JEVLIKEDECISIONS_APPROVAL_DIRECTORY" \
  --audit-store-path "$JEVLIKEDECISIONS_AUDIT_STORE_PATH" \
  --receipt-directory "$JEVLIKEDECISIONS_RECEIPT_DIRECTORY"
launchctl load "$HOME/Library/LaunchAgents/com.$USER.jevlikedecisions-mcp-http.plist"
launchctl kickstart -k "gui/$(id -u)/com.$USER.jevlikedecisions-mcp-http"
```

Use `kickstart -k` to restart the loaded label; do not load a second copy. This daemon is not a
singleton: another process on the same port fails to bind, and `KeepAlive` then retries it at
the configured `ThrottleInterval`. Without `JEVLIKEDECISIONS_MCP_HTTP_OPERATOR_TOKEN`, the
daemon binds loopback only. A coding tool's HTTP MCP server entry should point to
`http://127.0.0.1:<port>/mcp`, replacing `<port>` with the required operator-selected port.

## Paid live-proof utility

`scripts/run_paid_decision_live_proof.py` preserves the reproducible paid-call proof harness in the repository. It takes a configuration path rather than embedding one; `examples/paid_decision_live_proof.example.json` shows the input shape. By default it estimates every configured backend, prints the per-backend and total token/USD estimates, and exits without making a paid call. After reviewing that complete estimate, rerun with the explicit `--authorize-paid-calls` flag to execute every configured proof. Its records include reported-output-token status, cost completeness, and the known input-cost floor alongside the ledger and receipt evidence.

```console
PYTHONPATH=src python scripts/run_paid_decision_live_proof.py examples/paid_decision_live_proof.example.json
PYTHONPATH=src python scripts/run_paid_decision_live_proof.py examples/paid_decision_live_proof.example.json --authorize-paid-calls
```

`assert_all_decisions_via_jevlikedecisions(...)` cross-checks package-owned receipts against spendguard rows by `ledger_id` and fails closed when there are no auditable receipts. Spendguard's calls table has no endpoint, route, or via columns, so this proves receipt-backed package calls; it cannot prove that no outside caller contacted a Decisions endpoint.

## Capability documentation verdict maintenance

Capability documentation verdicts accumulate by exact `(subject, sha256(documentation))` pairs.
The documentation judge normally evaluates only pairs absent from the schema-current record,
retains byte-identical entries with their original per-entry instrument provenance and timestamp,
and reports every stale pair it prunes. Use `--force-full-rejudge` when the judging instrument
changes, including its model, prompt, sample count, or independent-run count; that mode replaces
every current pair rather than retaining evidence produced by a different instrument. Older record
schemas are refused and must be migrated explicitly before judging.

## Troubleshooting

A framework Python without a usable certificate bundle may need `SSL_CERT_FILE` set to the path of an installed CA bundle, such as the one supplied by certifi, before a real HTTPS call. Never put keys or secret values in configuration or examples.

`anyjev` remains a named v1 schema gap: its authoritative independent wire contract was unavailable, so no fields were invented. It can be added as another adapter without changing `decide()`.
## General LLM generation

This package wraps the jev and OpenAI Decisions APIs only. General text generation was removed; use `llm-spendguard` for general LLM calls.
