inferrail

0020. Lead with the payload-free cost-receipt promise: dual-SDK quickstart, verify-payload-free, quickstart budgets, and an opt-out usage beacon

Status

Accepted

Context

An audit of the live site and the first-run experience (this session, 2026-09-18) found that the product and the website had drifted away from Inferrail’s own founding claim – “know what your AI work costs, without keeping what it said” – toward leading with the newer AP invoice-exception-recovery capability instead. The founder asked for the payload-free cost-receipt story to become the single most prominent thing on the site and in the product’s first-run experience, with every existing capability (AP, Work Economics, budgets, the dashboard, MCP) reorganized to sit beneath and support it, not removed.

Concretely, the audit found:

The founder was asked explicitly, given that conflict, whether to keep the opt-in default or switch to opt-out. Explicit decision: switch to opt-out (on by default). This is a deliberate reversal of ADR-0019’s “default off,” recorded here plainly rather than silently changed — ADR-0019 itself remains in place as the historical record of the original decision and its reasoning; this ADR supersedes only its default-posture conclusion, not the rest of its design (the payload shape discipline, the no-baked-in-endpoint safeguard, the fail-silent networking, the independently-verifiable telemetry preview command all carry forward unchanged).

Decision

1. Quickstart speaks both SDKs

config/quickstart.py’s build_quickstart_config() now registers two providers – openai (OPENAI_API_KEY) and anthropic (ANTHROPIC_API_KEY) – each the passthrough default for its own wire format. Doing this correctly required a real (small, backward-compatible) config change: InferrailConfig gains default_anthropic_provider, separate from the existing default_provider, because the two pipelines (/v1/chat/completions vs /v1/messages) each build their own Router against disjoint provider sets (providers.registry.build_providers vs build_anthropic_providers) – one shared default could never correctly passthrough for both at once. gateway/app.py now constructs two Router instances instead of one, sharing config.routes but using each pipeline’s own default-provider field. Existing configs that only set default_provider are unaffected; default_anthropic_provider defaults to None, matching prior behavior (an explicit named route is required for Anthropic passthrough, exactly as before) unless a config opts in.

2. The quickstart banner is fixed and complete

_cmd_serve now line-buffers stdout for the whole process (sys.stdout.reconfigure(line_buffering=True), guarded by an isinstance check rather than a # type: ignore) before printing anything, so the banner can no longer be silently lost when stdout isn’t a TTY. The banner itself now prints the exact, copy-pasteable base_url/OPENAI_BASE_URL/ANTHROPIC_BASE_URL lines for both SDKs, the receipts path and how to read it (inferrail report/inferrail work), and a pointer to inferrail verify-payload-free.

3. inferrail verify-payload-free

A new command (cli/verify.py) that introspects the real, running InferenceReceipt.model_fields at call time – never a hardcoded string – lists every field, checks it against the same payload-capable-name set (prompt/messages/content/response) the existing test_inference_receipt_has_no_payload_fields regression test already enforces, and prints a plain PASS/FAIL statement plus the honest scope caveat (the provider still receives the real prompt; this is a receipt-storage guarantee, not a network privacy boundary). Suitable for pasting into a security review as-is.

4. Quickstart budgets

serve --daily-budget-usd AMOUNT (combinable with --quickstart, --app-mode, both, or neither) creates a global, block-mode, daily budget before the server starts. Combined with --app-mode, it reuses that mode’s already-sqlite budgets store; combined with plain --quickstart alone, it switches quickstart’s receipts to a dedicated local SQLite file for that run (./inferrail-receipts.db, distinct from the plain-quickstart JSONL default so neither format silently shadows the other in inferrail report’s own default lookup) – budget enforcement structurally requires an indexed store, the same requirement --app-mode already has. The banner states this plainly rather than leaving a silent format switch for the user to discover later.

5. --quickstart and --app-mode are no longer mutually exclusive

The prior CLI rejected --quickstart --app-mode together. There was no real reason for this: quickstart supplies providers/routes; app-mode relocates receipts/budgets under the OS app-data directory and mounts the dashboard + local control API. These are independent axes. Combining them gives a user the dashboard’s Live Feed (receipts arriving live) on top of the zero-config quickstart path with one extra, well-documented flag, without requiring a real inferrail.yaml.

6. ConsoleSummaryReceiptSink

A new ReceiptSink wrapper (receipts/console_summary.py, mirroring the existing UsagePingReceiptSink wrapping pattern) prints one compact line per receipt – model, tokens, cost or unknown, work_id if present, and an explicit “no prompt/response ever recorded” reminder – to stdout. Installed only for --quickstart, so a self-hosted operator running a real inferrail.yaml deployment doesn’t get an extra, unrequested stdout line per production request.

7. inferrail demo no longer touches real outcome data

cli/demo.py now writes its synthetic work-outcome rows to a dedicated ./inferrail-demo-work-outcomes.jsonl, never cli.work.DEFAULT_OUTCOMES_PATH (./inferrail-work-outcomes.jsonl) – the file a real user’s genuine inferrail work outcome records live in. The prior code both wrote and unconditionally .unlink()‘d that shared default at the start of every demo run, so running the demo after doing real work could silently delete real outcome history. Regression test: test_demo_never_touches_the_real_default_outcomes_path.

8. The usage-ping beacon: opt-out by default, four events, python_version added, always fires

Per the founder’s explicit decision above:

Consequences