Repository navigation
This root entry is condensed to fit the GitHub Release body limit (Phase 47, B-1). Full per-change detail is in each crate's CHANGELOG.md [0.11.0] section, in MIGRATION.md section 10, and in the uncondensed text at commit 700a92d86f3d6b2e24bbd2c163daec7c307297e0 (git show 700a92d86f3d6b2e24bbd2c163daec7c307297e0:CHANGELOG.md).
Added
Phase 51 — Adjustable Reasoning Effort
LlmPort::check_reasoningpre-judges a reasoning effort with no network call: success exactly when the send-time check would admit it, otherwise the sameLlmError::UnsupportedParametergeneratereturns. Every implementor compiles unchanged (REASON-01, REASON-04; DR-21).- Reasoning Effort guide page (
docs/src/user-guides/reasoning-effort.md), ADR-0062 (exact-or-refuse policy, amends ADR-0004) and live evidence:live_vendor_smokegains--effortand--effort-smoke; the 2026-10-22 transcript is in.planning/phases/51-adjustable-reasoning-effort/51-EFFORT-SMOKE.md(REASON-02, REASON-03, REASON-06, REASON-07). MIGRATION.mdgains the additive Phase 51 rows in 10.2, the six configuration keys in 10.5 and DR-20 to DR-26 in 10.8; the four deliberate breaks (CompatRequestParameters,PaladinData,AgentDefinition,TraceEvent::NodeFinished) carry cargo-semver-checks 0.50.0 lint ids measured against the published 0.10.1.TraceEvent::NodeFinishedcarriesreasoning_requestedandreasoning_effective(the effort asked for and the effort run with), read from that attempt's own response metadata; bothNonewhen no effort is in force, and aNonepair is not serialised, so older events and stored rows load unchanged (REASON-05, D-11).- Per-agent YAML in the
agents:list:reasoning_effort(a level,off,autoor{budget: N}) andreasoning_fallback(refuse,nearest_lower,nearest_higher); an unknown spelling fails config load naming the field (REASON-01, REASON-04). - Build-time refusals name the YAML key that set the effort: an agent's own effort its model cannot honour fails at load with an error ending in the YAML key path that set it; a block default is only checked, never copied onto the agent, and a refused one names
llm.<provider>.reasoning_effort(REASON-04). - Agent-level effort through
PaladinBuilder::reasoning_effortandreasoning_fallbackrides on every model call, beatsllm.<provider>.reasoning_effortand yields only to the request's own;PaladinBuilder::buildrefuses an effort the model cannot honour with aConfigurationError(REASON-01, REASON-04, REASON-05). - Fallback-chain pre-flight:
FallbackLlmAdapter::generate/generate_streamresolve the effort against every hop's own profile before the first call, and the first hop that cannot honour or substitute it fails the request withLlmError::UnsupportedParameternaming that hop, with no hop called;paladin.reasoning_requestedandpaladin.reasoning_effectivereachPaladinResult.run_metadata(plan 07; revised by plans 51-17 to 51-19). - Provider-level default:
llm.<provider>.reasoning_effortandllm.<provider>.reasoning_fallback, pluswith_reasoning_effort/with_reasoning_fallbackon all nine*Configtypes andLlmProviderConfig; applied per request against that request's own model, always yielding to the request's own effort; no provider has a compiled-in default (REASON-01, REASON-02, REASON-04; plan 06). llm.<provider>.reasoning_wire(LlmProviderConfig::with_reasoning_wire) declares the wire ofollamaandopenai-compatibleendpoints; every other provider refuses it at construction. All nine adapters honourLlmRequest.reasoningexactly or refuse before any HTTP call (plans 51-03 to 51-05; REASON-02, REASON-04). Additive: every touched type is#[non_exhaustive].- The five OpenAI-compatible presets (Kimi, Qwen, Grok, Ollama, generic OpenAI-compatible) declare their wire and send
LlmRequest.reasoningin exactly that field or refuse before HTTP;OllamaConfigandOpenAiCompatibleConfiggainreasoning_wireandwith_reasoning_wire(ReasoningWire); an unset effort leaves every wire body byte-identical (REASON-02, REASON-03, REASON-04; plan 04). - DeepSeek and OpenAI honour effort exactly or refuse before any HTTP call and before any retry: new module
paladin_llm::reasoningwithReasoningWire;ModelOverridegainseffort: Option<EffortSupport>(YAMLllm.<provider>.models.<model-id>.effort), validated at adapter construction; a substitutingReasoningFallbacklogs onewarn!(REASON-02 to REASON-06; plan 03). - Provider-neutral vocabulary:
ReasoningEffort(Off,Auto,Minimal,Low,Medium,High,XHigh,Max,Budget(u32)) andReasoningFallback(Refuseby default,NearestLower,NearestHigher);LlmRequestgainsreasoningandreasoning_fallback(REASON-01, REASON-02, REASON-04, REASON-06; plan 01). - One admission rule and the first typed refusal:
ModelProfilegainseffort: EffortSupport(fail closed:Unmeasured);LlmError::UnsupportedParameteris what an effort the model cannot honour produces before any HTTP call. Additive; every touched type is#[non_exhaustive], and the nine raw request bodies an unset effort produces are pinned byte for byte.
Phase 50.1 — Consumer-Surface Readiness for v0.11.0
with_base_urlandwith_timeout_secondson all nine adapter configs: the credential header goes whereverwith_base_urlpoints, so point it only at a trusted endpoint; redirects are never followed and the value must behttporhttps.with_timeout_secondsreplaces the constructor default (300 s for a seeded reasoning model) and the last call wins (SURFACE-02, D-05).- Pacing-only composition through the facade:
paladin::infrastructure::cadence::with_cadenceandpaladin::infrastructure::cadence::CadenceWiringare public re-exports besidebuild_cadenceandcompose_llm, so a consumer that builds adapters directly can pace without pricing; MIGRATION.md section 10.8 gains the matching consumer-guide entries, including M-B-05 (SURFACE-05, SURFACE-04, D-13). - The Aegis family through the facade: five whole-module re-exports resolve at the same path as in the core crate, and
paladin::preludealso namesAegis,ErrorHandlerSpec,TimeoutPolicy,StateDelta,NodeError,NodeErrorSource,TimeoutKind,NodeIdandTransience, so DR-1, DR-2, DR-8 and DR-9 need no direct core-crate dependency (SURFACE-01, D-01, D-02). - Seeded profiles for the current model range: 51 models gain a dated
SeededProfilerow from the operator's live probe of 2026-10-20, among themclaude-opus-4-8anddeepseek-v4-pro. Not seeded: the four OpenAI pro-tier ids,gemini-2.5-*and the rate-limited or struck ids;grok-4.7is seeded under a recorded exception (its range stops at 1.99) (SURFACE-06, D-14, D-15). - Behaviour for a seeded model (MIGRATION.md section 10.8): an explicit parameter its profile does not admit, or a
max_tokensabove its seeded maximum output, is refused at build;paladin.unprofiled_modelis no longer stamped; a directnewdefaults a seeded reasoning model to 300 s;AnthropicConfig::newstill sendsmax_tokens4,096;Commissary::for_modelrations against its seeded window; the OpenAImax_completion_tokenskey is now also measured on the 17 new OpenAI ids (ADR-0061). - The consumer-surface muster,
tests/consumer_surface_muster.rs, pins the surfaces a downstream consumer builds and matches; it runs in CI as theconsumer-surfacejob (Consumer-Surface Muster), which fails if it ran no test (SURFACE-03, D-09, D-10, D-11).
Phase 50 — Provider Parity & Configuration-Driven Model Swap
- The bill of a billed-but-failed call rides on the error:
paladin_ports::output::llm_port::BilledUsage { model, usage, cost }(#[non_exhaustive]), the constructorsLlmError::empty_completion(detail)andLlmError::refusal(provider, model, detail), andLlmError::with_billed_usage(billed)andLlmError::billed_usage(); the variant shape change is under Breaking Changes (G-50-5a, PARITY-05; plans 50-25, 50-26). - Attempt spend scope: a Battalion attempt is charged for every call it made, however it ends; a failed or timed-out attempt's
NodeFinishedcarries that spend, the superstep settle charges it once per attempt, and a successful Paladin attempt is never double counted. Additive (G-50-5a remainder (b), PARITY-05; plan 50-29). - Hopped-past fallback bills are settled with the call: when a
FallbackLlmAdapterhops past an error the vendor billed (aRefusalunderhop_on_refusal(true)),PricingLlmAdapterprices the hop'sBilledUsageagainst the hop's own model and the call settles in ONE ledger row under the existing(run_id, ordinal, 1)key with a per-modelmodel_breakdown(ADR-0053 section 4); a chain that runs out (AllProvidersFailed) settles every billed hop the same way. Plan 50-32 addsHoppedBills::install_pricerandHoppedBills::count_and_take(G-50-5a remainder (a), PARITY-05, PARITY-06; plans 50-30, 50-32). - Usage reconciliation: the five OpenAI-compatible presets parse the vendor's own
total_tokens, compare it with the mappedprompt_tokens + completion_tokensand check reasoning againstcompletion_tokensand cache reads plus writes againstprompt_tokens; a finding is never repaired, only logged once per adapter instance per (model, check) and recorded underpaladin.usage_unreconciled(USAGE_UNRECONCILED_METADATA_KEY), token counts only; plan 50-23 extended it to the four bespoke adapters (G-50-5a; plan 50-21). - Per-model parameter support (PARITY-02; plan 50-02; ADR-0061, amends ADR-0004):
paladin-portsgainsModelProfile,ParamSupport,StopSupportandSamplingParam, and the defaultedLlmPort::model_profile(&self, model: &str) -> Option<ModelProfile>returningNone, so every existing implementor compiles unchanged.ParamSupport::admitsis the single admission rule (no clamping,NaNnever admitted);PricingLlmAdapter,CadenceLlmAdapterandFallbackLlmAdapterforwardmodel_profile. Additive only. - Wire-time sampling filter (PARITY-02, PARITY-04, PARITY-05; plan 50-03; ADR-0061):
sampling::applyis the one filter every request builder calls; a set parameter reaches the wire only when the model'sModelProfileadmits it, and any other set value is omitted, never clamped, defaulted or swapped. Every omission is visible: onewarn!per adapter instance per (model, parameter) andpaladin.params_omittedon the bufferedLlmResponse. - Two more metadata keys and the model registry core:
paladin.response_format_adjusted(json_objectoromitted) records aresponse_formatthe profile could not honour, andpaladin.unprofiled_modelrecords a model with neither a seeded entry nor an override (never a refusal);registry::ModelOverride(deny_unknown_fields) is the per-model operator statement, laid over the seeded entry field by field. Owner reading R-1 (option-b, plan 50-18) carried the keys onto the stream's terminal chunk. - The dated per-model registry is seeded from a live probe (PARITY-04; plan 50-06; D-05/D-08):
paladin-llmgains one dated table per vendor (Anthropic, OpenAI, DeepSeek, Kimi, Qwen, Grok, Gemini, Ollama), every value tracing to the operator's live seed probe of 2026-10-15; a listed model gets its measured facts and itsverified_ondate, and any other model runs on the adapter-wide fallback, visibly unprofiled. Ollama's table is empty because the probe skipped it. New public constantsANTHROPIC_DEFAULT_MODELandDEEPSEEK_DEFAULT_MODEL. - The Commissary and the HistoryTrimmer ration against the model's own window (PARITY-04; plan 50-07; ADR-0061, D-05): paladin_llm::window::effective_capabilities returns the port's
ProviderCapabilitieswithmax_context_tokensreplaced by the model profile'scontext_windowwhen it has one, andCommissary::for_model(port, model, counter, plan)builds a Commissary over it, sokimi-k3is rationed against its seeded 1,048,576-token window instead of the adapter-wide 262,144. TheHistoryTrimmerresolves its limit the same way;Commissary::newandCommissary::from_portare unchanged. - Configuration knobs and config-driven construction (PARITY-03; plans 50-08, 50-09, 50-10; D-12, D-13, D-14, D-00f): each of the nine adapter configs carries
max_tokens,max_retries,context_windowandmodel_overrideswith#[must_use]builders; an invalid override or a window below a model's maximum output fails adapter construction naming the provider and model; configs that hold a key (all butOllamaConfig) print it as<redacted>fromDebug.LlmProviderFactory::create_from_config(&LlmConfig, provider, model)builds any of the nine adapters from itsllm.<provider>block (new optional keysmax_tokens,context_windowandmodels.<model-id>); each input comes from the most specific statement that sets it (per-agent model, block, legacy environment variables, compiled default), and an environment variable never overrides a value written in the block.api_keymay be empty or an exact${NAME}placeholder; an unset placeholder variable is a construction error, never sent to the vendor, and every error names the provider, the model and the variable tried, never a key value; a malformed model id is refused before any adapter is built. - The facade exports all nine adapter/config pairs and the
llm:config types (PARITY-03; plan 50-10; D-14, D-00f):paladinre-exports the Kimi, Qwen, Grok, Gemini, Ollama and OpenAI-compatible adapter/config pairs behind theirllm-*features, andLlmConfig,LlmProviderConfigandModelOverrideunconditionally; ADR-0046's default features are unchanged (llm-openai,llm-anthropic,llm-deepseek).FacadeProvisioner::with_llm_configcarries the operator'sllm:section to runtime-provisioned agents. - Typed reasoning text and a constructor for
LlmResponse(PARITY-05; plan 50-11; D-11):StreamingResponsegainsreasoning: Option<String>(additive:#[non_exhaustive]) and the chainableStreamingResponse::with_reasoning;LlmResponsegainsLlmResponse::new(request_id, model, content, finish_reason, usage), leaving the other fields neutral, and the chainableLlmResponse::with_reasoning. Both storeNonefor text that is empty after trimming; reasoning is a typed field, nevermetadata. LlmError::Refusal(PARITY-05; plan 50-12; D-09, D-11, D-11a):LlmErrorgainsRefusal { provider, model, detail }, a vendor's own refusal: permanent (never retried), distinct from aContentFilterfinish and from an empty answer;detailis the vendor text with credentials removed first and then bounded to 512 characters. The Kimi, Qwen, Grok, Ollama and OpenAI-compatible presets and OpenAI report it.- Reasoning and cache-write tokens: the compat presets and DeepSeek fill
LlmResponse.reasoningand each streamedStreamingResponse.reasoningfromreasoning_content, and the compat presets mapprompt_tokens_details.cache_write_tokensintoTokenUsage.cache_write_tokens.MIGRATION.md10.1 M-B-16. - A fallback chain can send each hop its own model (PARITY-06; plan 50-14; D-15, D-10, D-00h):
FallbackStep { port, model }andFallbackLlmAdapter::from_steps(Vec<FallbackStep>)build a chain whose every hop is sent its own model, ongenerateandgenerate_stream, and that model is what the hop is paced under, so a 429 on hop 1 gates(hop-1 provider, hop-1 model)only.FallbackLlmAdapter::newis unchanged. Every served response records the serving hop's model underpaladin.served_modelbesidepaladin.served_by. FallbackLlmAdapter::hop_on_refusal(bool)(defaultfalse) is the explicit opt-in to trying the next step when a hop answersLlmError::Refusal; every other permanent error still stops the chain.FallbackChainError::EmptyStepModel { index }rejects an empty step model at construction.agent_runtime.model_fallback.steps(PARITY-06; plan 50-14; D-15): the configuration path sends each fallback hop its own model too,steps: [{provider, model}]resolved throughLlmProviderFactory::create_from_configagainst the newAgentRuntimeDeps.llm_configand built withModelFallbackMiddleware::from_steps/paced_steps(names only, never a credential). It is mutually exclusive withproviders(a validation error naming both keys); aproviders-only configuration builds as before.MIGRATION.md10.5.PaladinResult.run_metadatacarries the run'spaladin.*visibility keys (PARITY-02; plan 50-18; ADR-0061 (h), owner reading R-1 =option-b): the keys every model call stamped (paladin.params_omitted,paladin.unprofiled_model,paladin.response_format_adjusted,paladin.served_model), so an omittedauto_temperature, an unprofiled model or a fallback hop is visible on the result without scraping logs. A key without thepaladin.prefix is never copied; an empty map is absent from the serialised JSON, so a payload written before this field existed is byte-identical;paladin-web'sExecuteResponseis not extended.- Streamed calls carry the same visibility keys on their terminal chunk (PARITY-02; plan 50-18; ADR-0061 (h), owner reading R-1 =
option-b):StreamingResponsegainsmetadata: HashMap<String, String>(additive:#[non_exhaustive]; empty and then absent from the serialised JSON). The compat engine, OpenAI, DeepSeek, Anthropic and Gemini stamp the keys their buffered path stamps (paladin.params_omitted,paladin.unprofiled_model,paladin.response_format_adjusted) on the terminal chunk only; anErrterminal item (refusal, empty completion) carries none, and aFallbackLlmAdapterstream names the serving hop withpaladin.served_byandpaladin.served_model. ChunkMetadatagainsrun_metadata: BTreeMap<String, String>andChunkMetadata::with_run_metadata, populated by the same merge rule asPaladinResult.run_metadata; the SSEdoneevent ofpaladin-webis not extended.- The Switching providers page, its drift test, the widened live smoke and the 17-case conformance suite (PARITY-07, PARITY-01, PARITY-08; plans 50-01, 50-04, 50-15, 50-16):
docs/src/user-guides/switching-providers.mdexplains the configuration-only swap, the library path, the ADR-0061 parameter policy, the window precedence, the 300 s reasoning default and fallback chains, with per-provider tables for all nine providers; the registry testswitching_providers_page_lists_every_seeded_modelfails when the page and the seeded tables disagree.tests/provider_swap_matrix.rsruns one agent definition against all nine providers built from anllm.<provider>block (50 cases, none ignored); the shared conformance suite grew from ten to seventeen cases and now measures all nine adapters, Anthropic included. - The
live_vendor_smokeexample covers all nine providers, credential-gated (SKIPPEDfor an absent credential, never a pass), with--probe,--modelsand--dry-run. Test and example surface only; no public API change.
Phase 49 — Downstream Adoption of v0.10.1
- Consumer-surface muster and library-consumer upgrade note (ADOPT-04, ADOPT-05; plans 49-01, 49-02):
tests/consumer_surface_muster.rs, a compile-only integration test ([[test]] consumer_surface_muster,required-features = ["llm-anthropic", "llm-deepseek"]), pins every path, signature, struct shape and trait the downstream consumer takes from Paladin;make check-consumer-surfaceruns it and is not part ofcheck-gates. MIGRATION.md section 10.8 gains### Library consumers: v0.10.1 → v0.11.0with an eleven-item break list, each item pointing at its existing §10.2 row; no library API, §10.2 row or allowlist entry changes.
Phase 43 — Rate Pacing
- The Cadence (PACE-02; plan 43-01): a new
CadencePortoutput port (paladin-ports), the in-processInMemoryCadence(paladin-storage) and theCadenceLlmAdapterdecorator (paladin-llm), composed asPricing(Cadence(provider))on every agent the server builds from configuration or provisions at runtime. A provider 429 gates later calls to that provider and model for an exponential back-off with full jitter (500 ms to 30 s); the decorator never retries. On by default, configured undertreasurer.cadence(enabled: falserestores the previous behaviour); the OpenAI adapter now surfaces its first 429 instead of retrying it inside its own loop. - Plans 43-05 and 43-06: streaming calls (
generate_stream) are paced exactly like buffered ones; the callers one gate releases are spread by up tomin(wait / 10, 1 s), never below a provider's explicit delay;InMemoryCadenceholds at mostDEFAULT_KEY_CAPACITY(4096) keys;paladin_storage::cadence::contract_testsholds every adapter to the same rules;randis now a required dependency ofpaladin-llm. Fallback chains pace first and hop last:FallbackLlmAdapter::with_cadencewraps every hop in the Cadence decorator, and a 429 on a hop is retried on the same provider after its gate for up totreasurer.cadence.fallback_pace_budget_secs(default 60) before the chain hops; a 5xx, timeout or network error still hops at once.ModelFallbackMiddleware::pacedbuilds a paced chain andAgentRuntimeDeps.cadencepaces every config-builtmodel_fallbackchain; set it toNoneto opt out. - Plans 43-07 and 43-08, fleet-wide pacing through Redis (PACE-03, PACE-05):
RedisCadenceandRedisCadenceConfiginpaladin-storage, behind the newredis-cadenceCargo feature (also apaladin-aipassthrough, in neitherdefaultnorfull); a 429 recorded by one worker gates every worker on the same Redis server for that provider and model. One atomic Lua script per operation on the Redis server's own clock; the URL's password is redacted inDebugand in every error; every key carries a TTL; the CI jobredis-cadence-integrationfails if it was skipped.ResilientCadencemakes a Redis outage a degradation, never a failure: it serves from the shared backend while it answers and, on the first error or timeout, from an in-process fallback built withInMemoryCadence::with_multiplier(delays multiplied, default 2.0), warns once per outage and probes the backend at most everyDEFAULT_PROBE_INTERVAL(5 s). - Plan 43-09, the operator surface (PACE-02, PACE-03, PACE-05):
treasurer.cadence.backendaccepts{ redis: { url_env: NAME } }, naming the environment variable that holds the Redis URL (validated at boot; the URL itself is never stored, logged or serialised); on a binary built withoutredis-cadencethat backend is a boot error naming the feature. Seven scalar keys gainAPP_TREASURER_CADENCE_*environment overrides. The run engine's own port is paced, andpaladin-serverbuilds one wiring shared by the resident agents, the runtime provisioner and the run engine (new:build_agent_registry_with_cadence,paladin_port_from_settings_with_cadence,build_run_api_with_cadence,FacadeProvisioner::with_cadence; the existing entry points keep their signatures and stay paced). - Plans 43-10 to 43-12, stampede lock (PACE-04):
CadencePortgainstry_lockandunlockwithLockKeyandFencingToken(tokens increase strictly per key;Distributedfrom Redis,Localfrom one process; never compared);ResilientCadencefails the lock open onto the in-process lock during an outage.NodeCachePortgains the defaultedput_fencedandRedisNodeCacheoverrides it so a stale lock holder's late write cannot overwrite a newer entry;WarEngine::with_cadence(port, lock_ttl)puts the lock around the node cache's miss-to-store window so concurrent identical cache misses execute a cache-policy node once, and a lock error, an elapsedlock_ttlor a cancelled run never fails a node. Plan 43-12 records every provider header name and error code the parser depends on against the provider's own documentation, and ADR-0058 (.planning/decisions/0058-rate-pacing-cadence.md) records the design; theRateLimitExceededexamples in the provider contribution guides now show the real struct variant. - Known limitations of pacing, which is reactive and bounded, not a guarantee a provider never refuses a call: (1) the Redis namespace is the fixed
paladin:cadence, with no key for it intreasurer.cadence, so two independent fleets on one Redis server share pacing state; (2) during a Redis outage a worker paces from its own429s only, with delays multiplied bydegraded_multiplier, a gate written to Redis before the outage is invisible until recovery, and recovery is detected within one probe interval (5 s); (3) Gemini's body-levelRetryInforetry delay is not parsed (Retry-Afterwhen present, the back-off estimate otherwise); (4) CLI one-shot commands are not paced; (5) aFallbackLlmAdapterbuilt by hand and never givenwith_cadencekeeps hopping immediately on a 429; (6) the stampede lock is not renewed:lock_ttl_secsmust exceed the p99 node duration including retries, and the server attaches no node cache today, so it attaches no lock. - Provider retry delay on a 429 (PACE-01, PACE-02; plan 43-02):
LlmError::RateLimitExceededcarries the provider's own retry delay and a rate-limit header snapshot, read throughLlmError::retry_after()andLlmError::rate_limit_hints(); the newRateLimitHints,RateLimitDimension,RateLimitDimensionKindandRetryDelaySourcetypes (paladin-ports) hold parsed numbers and durations only, never a raw header string. A delay is never guessed. The Cadence honours an explicit delay as a minimum and surfaces a delay beyondtreasurer.cadence.max_wait_secsimmediately as a typedRateLimitExceededinstead of holding a worker (D-06). The 429Displaytext and itsTransientclassification are unchanged. - Provider rate-limit headers on a 429 (PACE-01; plan 43-03): a shared parser,
paladin_llm::rate_limit_headers, turns a 429's headers into theRateLimitHints(Retry-Afterin delta-seconds or HTTP-date form,retry-after-ms, OpenAI'sx-ratelimit-*, Anthropic'santhropic-ratelimit-*); the OpenAI and Anthropic adapters read them on the buffered and streaming paths. Parsing is panic-free, every delay is clamped to 24 hours and no raw header value reaches an error or a log line;map_http_status_with_hintscarries the hints (map_http_statusis unchanged);httpdateis now a direct dependency ofpaladin-llm.
Phase 42 — Mid-Run Halt & SSE Terminal Status
- Mid-run allowance halts on the engine path (ALLOW-03; plan 42-02, ADR-0057):
WarEngineconsults a newSpendGuardoutput port (paladin-ports) at every superstep boundary, and theTreasureranswers through the same ceiling evaluation admission uses, so an allowance exhausted in flight halts the run at its next boundary with its last checkpoint kept: recordedhalted, no error, aWaypointStatus::HaltedWaypoint, no node of that superstep runs. Overshoot is at most one top-level superstep's spend, including any nestedBattalionrun. RunOutcome::Haltedcarries a typedHaltCause(caller cancel, in-process token, orSpend(HaltReason));RunWorkerPool::with_treasurerattaches the guard per run for every run that records a submitter. New public surface:SpendGuard,SpendDecision,NeverHalts,HaltReason,WarEngine::with_spend_guard,Treasurer::spend_guardandTreasurerSpendGuard; seeMIGRATION.md§10.2.- Persisted halt reason (plan 42-03): migration
013_add_run_halt_reason.sql(both backends) adds one nullableruns.halt_reasoncolumn (NULLfor every legacy row); a spend-halted run'sGET /v1/runs/{id}andGET /v1/runsrows carryhalt_reason(the halted ceiling's figures, the same keys as the429 allowance_exhausteddetails, or{"reason":"ledger_unavailable"}) andfinal_waypoint_id, witherrorstillnull; thehaltedrun webhook gains the same optionalhalt_reasonkey, other events and the signature unchanged. - SSE terminal status names the halt reason (plan 42-05):
TraceEvent::RunFinishedgainshalt_reason: Option<HaltReason>(skipped whenNone; storedrun_tracesrows without it read back asNone); thedoneevent ofGET /v1/runs/{id}/streamfor a spend-halted run carrieshalt_reasonon the live, degraded and replay paths; a ledger-unavailable halt is adonewith{"reason":"ledger_unavailable"}, never anerror. SeeMIGRATION.md§10.2 and §10.6. - Resumable by fork (plan 42-04): a halted run resumes by forking from its
final_waypoint_id(POST /v1/threads/{id}/fork); the fork re-runs admission, so it is refused429 allowance_exhaustedwithRetry-Afterwhile the window is exhausted and, once it resets, continues from the Halted Waypoint without re-running a completed superstep. No new public API. - A halt rung for the operator notice path (plan 42-10): migration
014_add_treasury_notice_kind.sql(both backends) addstreasury_notices.notice_kind('warning'or'halt', default'warning', so every legacy row stays a warning); a second halt notice for the same identity isAlreadyRecorded.NoticeKindis new, andNoticeRecordandAllowanceNoticegain akindfield (a struct literal must addkind: NoticeKind::Warning).RunEventKindgainsAllowanceHalted(allowance_halted), an operator-only event signed with the operator notice secret likeallowance_warning; callers cannot subscribe to it. SeeMIGRATION.md§10.2 and §10.4. - Mid-run allowance notices (plan 42-11): a run that crosses
warn_atwarns at its first superstep boundary past the threshold (oneallowance_warningtrace event and one operator delivery, once per window), and a spend halt sends the operator oneallowance_halteddelivery per scope, limit, window and ceiling; aledger_unavailablehalt notifies nobody, and claims never change the guard's decision. Enable on every replica before relying on it: an older build cannot parseallowance_halted. SeeMIGRATION.md§10.6. - The halt reason in the heralds and a vocabulary guard (plan 42-12): a Treasurer-halted run renders one halt line in the markdown, JSON and table heralds (for a ledger outage,
⛔ halted: allowance could not be evaluated (ledger unavailable)); it names the scope kind, the figures and the window end, never a tenant id, a key name or a key value.tests/treasurer_vocabulary_guard.rskeepsTreasurera framework-only word (ALLOW-05, ADR-0050). The three known windows the Phase 42 entry leaves open are under Known limitations. - A per-run token budget derived from the remaining allowance (ALLOW-05; plan 42-07, ADR-0057): the
Treasurerturns what is left of a principal's tightest allowance ceiling into a token count for the agent loop (floor(remaining * 1_000_000 / dearest price), computed ini128and saturated tou32;Treasurer::with_pricing,Treasurer::derive_budget, the new defaultedAllowanceAdmissionPort::admit_for_model). A free model gets no budget, a zero figure is refused like an exhausted allowance, and a principal with a ceiling calling a model with no price row is refused with the newAdmissionError::ModelUnpriced. - The one existing
TokenBudgetcutoff enforces the tighter of the operator's figure and the derived one; when the allowance binds, including a tie, the run ends with the newStopReason::AllowanceHalted(stop reasonallowance_halted; neither successful nor a failure) after the response that crossed it, keeping that response and the truncation notice.ModelCallLimit,ToolCallLimitand the Commissary are unchanged. SeeMIGRATION.md§10.2. - The HTTP agent routes stop on the allowance (plan 42-08):
POST /v1/agents/{id}/execute,execute/streamandjobsadmit with the agent's model and hand the derived budget to the run; a call whose derived budget is crossed answers200withstop_reason: "allowance_halted"and ahalt_reasonobject (ExecuteResponse.halt_reason), keeping the partial output. A true streamedexecute/streamcall is one provider call and is not cut mid-flight; itsdonestays{ "done": true, "usage" }byte for byte unless the final chunk'stotal_tokensstrictly exceeds the derived figure. - Plan 42-08 also refuses, on all three routes and before the agent runs, a principal with an allowance ceiling calling an agent whose model has no
treasurer.pricingrow (422 model_unpriced,ApiError::model_unpriced), and a derived budget of zero (429 allowance_exhausted). The frozen v0.9 golden gate carries a fourth sanctioned exception for the two additive wire changes, andopenapi.jsonis regenerated; seeMIGRATION.md§10.2 and §10.6. - Agent-kind runs stop on the allowance (plan 42-09): a run of an agent-kind assistant (
Runnable::Agent) is bounded by the caller's allowance.POST /v1/runsadmits it with the agent's model, so an unpriced model under a ceiling answers422 model_unpriced(the newRunSubmissionError::ModelUnpriced { model }) and an allowance too small for one token answers429 allowance_exhausted, before any run row. - Agent-kind runs end after the response that crosses the derived budget, recorded
halted(it wascompleted, unbounded, before) witherror: nulland the partial output kept; a zero or exhausted figure at dispatch, or an unreadable ledger (ledger_unavailable), records the same halt without calling the model; a model that lost its price row recordsfailednaming the model. Agent-kind runs have no checkpoint: resume by a freshPOST /v1/runs(recorded in.planning/WINDOWS.md).
Phase 41 — Admission-Time Allowance Enforcement
- Facade re-export of the allowance module (plan 41-09, D-20): the
paladinfacade re-exportspaladin::core::platform::container::allowance, so a facade user needs no directpaladin-coredependency; ADR-0056 records the admission model. - Per-API-key rolling-window allowances, refused at admission (ALLOW-01, ALLOW-02; plan 41-01): a
treasurer.allowance.api_keys.<name>: { period, amount }config subtree (periodas<integer><m|h|d>from1mto366d; omitted, it is inert) is checked by the newTreasurerservice (AllowanceAdmissionPort,paladin-ports) against the new defaultedTreasuryLedgerPort::balanceread. - An exhausted caller is refused by
POST /v1/runswith429 allowance_exhausted(error.details:scope,kind,balance,ceiling,window_start,window_end) and aRetry-Afterfrom the ledger's own clock, before any run is persisted; a request with no principal, or a principal with no allowance entry, is never gated. Windows are tumbling and aligned to the UTC epoch, so a tenant can spend up to twice an allowance across one boundary, by design. New public surface:RunSubmissionService::with_treasurer,RunSubmissionError::AllowanceExhausted,ApiError::allowance_exhausted; seeMIGRATION.md§10.2 and §10.6. - Plan 41-03 completes the operator grammar:
treasurer.allowancealso takestenants.<id>entries, an optionallifetimecap,warn_at(default80) and an optional operatorwebhook { url, secret }target (the secret is redacted fromDebugandSerialize; supply it withAPP_TREASURER_ALLOWANCE_WEBHOOK_SECRET, since a${VAR}placeholder in YAML is not expanded).paladin-serverrefuses to boot, naming the path, when an allowance entry is set whilerun_store.backendisdisabledor names an API key or tenant the authentication configuration does not know. - Plan 41-04 closes the coverage: the same admission check also gates
POST /v1/threads/{id}/forkand the three HTTP agent routes that start spend (POST /v1/agents/{id}/execute,/execute/streamand/jobs); anAdminprincipal is bound like any other, and a failing allowance check answers500and runs nothing. The429is published incrates/paladin-web/openapi.json, and the frozen v0.9 golden gate carries a third sanctioned exception for it. - Plan 41-05, schedule-fired runs:
POST /v1/schedulesrecords the creating principal's tenant id and API key name (migration010), every fired run is attributed to that creator, and a tick whose creator's allowance is exhausted is skipped (SkipReason::AllowanceExhausted, counted inskipped_ticks). A schedule created before this release has no recorded creator and keeps firing unattributed and ungated. - Plans 41-06 and 41-07, warn notices: a balance that reaches
warn_atpercent of a ceiling on an admitted request claims one durable notice per scope, limit, window and ceiling (newtreasury_noticestable, migration011; newTreasuryNoticePort;Treasurer::with_notices), enforced by the store so it holds across replicas; raising a ceiling re-arms it, and a notice-store failure never blocks or fails the run. Each won notice becomes exactly oneTraceEvent::AllowanceWarning(never a tenant id or key name) on the admitted run's own trace stream, emitted on its first dispatch beforeRunStarted, and the heralds render one allowance line. Upgrade every replica before settingwarn_at: an older build reads the newrun_tracesrecord kind as unknown. Migration012adds a tenant-widetreasury_ledger (tenant_id, attributed_at)index. - Plan 41-08, operator webhook: with
treasurer.allowance.webhookconfigured, every notice an admission wins is delivered once to the operator URL through the durable webhook queue with the newRunEventKind::AllowanceWarning(RunEventKindis now#[non_exhaustive]), signedX-Paladin-Signature: sha256=<hex>with the operator secret (held on the delivery service, never on the row) and carryingX-Paladin-Event: allowance_warning. The twelve-key payload never carries run input, a key value or a secret; a caller cannot subscribe a webhook toallowance_warning;paladin-serverSSRF-checks the URL at boot (a private target needswebhooks.allow_private: true). - Enable the webhook only after every replica runs this build: an older replica rejects the unknown
allowance_warningrow for its whole delivery batch. Omittingwebhookdisables only this leg.
Phase 38 — Design Seams & Pricing/Cost Producer
- Operator-configured treasurer price table, wired into both production run paths (PRICE-01, PRICE-03; plan 38-03): a per-model price table as decimal strings per 1M tokens under
treasurer.pricing, validated at boot with a path-precise error naming the offending model and axis by a hand-rolled exact-integer parser (norust_decimal, no new dependency) that rejects negative, malformed, too-fine (>9 decimal places) and overflowing prices. Omitting the section changes nothing (an empty table in"USD"); onlytreasurer.currencyhas an env override (APP_TREASURER_CURRENCY).build_agent_registry,FacadeProvisionerandpaladin_port_from_settingswrap their resolvedLlmPortwithpaladin_llm::pricing::with_pricingbefore any call. Purely additive. - Treasurer cost arithmetic and the streamed cost producer (PRICE-02, PRICE-03; plan 38-02):
Cost(i64nano-unit amount plus a validatedCurrencyCode),PriceRow/PriceTable, the purecost_of_callandCostTally(unpriced poisons the run total, never a partial sum); every intermediate product is computed ini128, rounded half-up once per call and saturates intoi64, with no floating point.PricingLlmAdapterprices a streamed call's terminal chunk from its own usage, with a warn-once log line per unpriced model;StreamingResponseandChunkMetadatagain an additiveOption<Cost>field (both#[non_exhaustive]), andMarkdownHerald::finalize_streamrenders0.0450 USDinstead of a hard-coded$prefix. Purely additive. - ADR-0052 and ADR-0053 (PRICE-02, PRICE-03; plan 38-01): metering lives in the
PricingLlmAdapterdecorator at theLlmPortboundary on both run paths, with the halt raised at theWarEnginesuperstep boundary and the agent loop's existingTokenBudgetcutoff; the treasury ledger is append-only with the balance derived on read,reserve/settle/releaserow kinds and a(run_id, superstep, attempt)settlement idempotency key. - Cost priced on the non-streaming
LlmPort::generatepath (PRICE-03; plan 38-04):LlmResponsegains an additivecost: Option<Cost>field afterusage;PricingLlmAdapter::generateprices each response from its own served model and usage, surviving aFallbackLlmAdapterhop to a differently-named model (D-08). - JSON and table heralds render cost and currency (PRICE-03; plan 38-05):
JsonHerald::finalize_streamemits acurrencyfield besidecost_estimate(nullfor both when unpriced);TableHerald::finalize_streamrenders the run's realExecutionMetadatain place of four hard-coded placeholder rows. - Cost carried on the trace stream and summed per run, on both run paths (PRICE-03; plans 38-06, 38-07, 38-08):
TraceEvent::NodeFinished/RunFinishedandPaladinResultgain additivecost: Option<Cost>fields;TraceDispatcher::total_cost()folds every priced node synchronously insideemit()under theCostTallyrule (one unpriced call poisons the run total, never a partial sum), soRunFinished.costis a real end-to-end figure on both paths, never a fabricated zero.ExecutionMetadata::from_run_finished(record, model_used)and the newHeraldTraceSinkhand a realExecutionMetadatato an operator-configuredHerald'sfinalize_streamon every engine run, viaRunWorkerPool::with_heraldandsettings.herald. SeeMIGRATION.md§10.2.
Phase 39 — Spend Ledger
TreasuryLedgerPort, the durable spend ledger on three adapters behind one contract suite (LEDGR-01; plans 39-01, 39-02, 39-03):paladin-ports::output::treasury_ledger_portaddsTreasuryLedgerPort(reserve/settle/release/spend/store_now) andTreasuryLedgerError;InMemoryTreasuryLedger,SqliteTreasuryLedgerandPostgresTreasuryLedger(paladin-storage::treasury) all pass one shared contract suite unmodified, including 16 concurrent reserves against a ceiling of 15 admitting exactly 15 and refusing 1 (LEDGR-02).- Migration
007_create_treasury_ledger_tableis append-only, withi64nano-unit amounts with an ISO 4217 currency code, a partial unique settlement index on the settlement key (LEDGR-03) and amodel_breakdowncolumn (D-02). Production writers stamp the documentedLedgerScope::unattributed()sentinel scope until Phase 40 replaces its source (D-01). - Race-proof
reserveand idempotentsettle/release, serialized per scope (LEDGR-02, LEDGR-03; plans 39-02, 39-03): SQLite opens each reserving transaction with BEGIN IMMEDIATE; Postgres takes a transaction-scoped advisory lock keyed on the scope; the in-memory adapter holds onetokio::sync::Mutex; all three admit a hold only whenbalance + hold <= ceiling, refusing with a typedTreasuryLedgerError::Refused { balance, hold, ceiling }. A duplicate settlement key is a success (SettleOutcome::AlreadySettled), never an error. paladin-cli treasury spend, queryable spend with no server (LEDGR-04; plan 39-01): a newtreasurysubcommand group reads the configuredRunStoreConfigstore directly (--since/--until,--format table|json); amounts print as four decimals plus the currency code (0.0450 USD), and a window spanning two currencies prints one row per currency, never combined.- Production settle-only writers fill the ledger on both run paths (LEDGR-03, LEDGR-04; plans 39-04, 39-05, 39-07):
WarEngine::with_treasury_ledger(ledger, SettlementContext)settles exactly one aggregated row per superstep attempt, awaited at the superstep boundary, including nested Battalion child runs and Aegis-retried nodes (only the eventual successful attempt contributes);PaladinExecutionService::with_treasury_ledgerand the newAgentLoopSettlementenum (EveryCall/PlatformRunsOnly) settle every priced agent-loop model call exactly once, andPlatformRunsOnlykeeps the run engine's shared agent service from double-charging a node the engine already settled. A ledger failure is logged and never fails, retries or halts a run (the ledger is observational until Phase 41). RunScope::with_run_idand the production ledger composition points (LEDGR-04; plans 39-05, 39-07):RunScopegains a run id and a with_run_id builder;build_agent_registry_with_ledger,FacadeProvisioner::with_treasury_ledger/paladin_port_from_settings_with_ledgerandbuild_treasury_ledger(&RunStoreConfig)are the production installation points; the existing ledger-less functions keep their signatures.
Phase 40 — Tenant Identity & Run-Read Scoping
- Tenant identity (TENANT-01; plans 40-01, 40-03; ADR-0054):
Principal(paladin-web) gains a requiredtenant_id: TenantId, looked up from operator configuration insideauthenticate()and nowhere else; no request header, query parameter or body field can name a tenant. The key-to-tenant mapping ishttp.auth.api_keys[].tenant(required on every key) andhttp.auth.bearer_token.tenant(required when bearer auth is enabled);AuthConfig::validatechecks both at boot and also rejects an empty key value, an invalid or duplicate keynameand a duplicate key value, never printing a secret. There is no implicit default tenant. SeeMIGRATION.md§10.2, §10.5 and §10.8 step 3. - Run attribution (TENANT-02; plans 40-01, 40-02, 40-04, 40-05): every run records the submitting tenant and API key (nullable
runs.tenant_id/runs.api_key_idcolumns, migration008,RUN_SCHEMA_VERSIONunchanged), surfaced byGET /runs/{run_id}andGET /runsasRunResponse.submitted_by(nullfor an unattributed run; never the key value, never the role). Ledger settlements carry the same tenant and API key (LedgerScope::from_attribution), and the HTTP agent-execute routes settle under the calling principal through the new defaultedPaladinExecutorPort::execute_scopedandStreamingExecutorPort::execute_stream_scoped(every existing implementor compiles unchanged). The Phase 39unattributedsentinel is written only where no principal exists (schedule-fired runs, same-process embedders). - Tenant-scoped run reads with an Admin bypass (PLAT-07; plans 40-01, 40-02, 40-05; ADR-0054):
RunReadScope { All, Tenant(TenantId) }is the one visibility rule: a principal sees a run iff the run's recorded tenant is its own, or its role isAdmin(an unattributed run is visible to Admin only). It applies inside everyRunRepositoryPort::listadapter, andload_visible_rungates every/runs/{run_id}*route. Another tenant's run answers the byte-identical404 unknown runa missing run does, never403. ClosesWINDOWS.mdrow 32 (WR-03).
Phase 45 — RustFS Swap & Platform/Observability Deviations
- The
AdvancedFileStoragePortmultipart trio is implemented onMinioAdapterbehind an opaque, self-describing upload-id token re-validated on every decode, and theFileStoragePortcontract suite gains multipart, ETag and exercised-presigned-URL cases, taking it to 11 cases (STORE-02; plan 45-01).
Changed
Phase 46 — Docs Currency & Hygiene
- A Commander running a Maneuver warns when
aegis.on_errororaegis.retryis set (D-24 / WR-04; plan 46-09;MIGRATION.mdM-B-11): Maneuver reads onlyaegis.timeout.run_timeout, as one whole-flow clock, so the Commander logs onewarn!per execution naming the Battalion and the ignored field. No behaviour other than the log line changes. make check-gatesand CI pin thepaladin-coreandpaladin-ports[dependencies]to ADR-0015's measured sets (plan 46-05, D-21): the new offline gatecheck-dependency-allowlist(scripts/check-dependency-allowlist.sh, the ninthcheck-gatesmember, and a step inci.yml) fails when either crate's[dependencies]table gains or loses a crate outside the allowlist. No public API or dependency changes.
Phase 51 — Adjustable Reasoning Effort
- Build, load and chain pre-flight judge the provider-level defaults too (REASON-01, REASON-04; plans 51-16 to 51-20, gap G-2;
MIGRATION.md10.2 and 10.8 DR-21):PaladinBuilder::buildjudges the agent's effort, or the port's provider-level default when the agent sets none, throughLlmPort::check_reasoning, so a Paladin over a directly built adapter whose default its model cannot honour is refused at build (behaviour change A2-51-17: it used to fail on every call). TheFallbackLlmAdapterpre-flight judges every hop with its own provider-level defaults, andFallbackLlmAdapter::model_profilereports effortUnmeasuredwhen any hop has no profile. A deployment that sets no effort and no provider-level default is unaffected. - Native-wire efforts (REASON-02, REASON-03, REASON-04, REASON-05, REASON-06; D-00h, D-01, D-04; plans 11, 12): Anthropic, OpenAI, DeepSeek, Gemini, Kimi, Qwen and Grok accept a reasoning effort on the models the 2026-10-22 live probe measured (
51-EFFORT-PROBE.md, commit32ce9dd0); every other model (gpt-4o-mini,chat-latest, any id with no row) and any effort the probe did not evidence is refused before HTTP naming thellm.<provider>.models.<model-id>.effortoverride. No public item changed. - Per-model results of the live probe: OpenAI's 17 probed models, Anthropic's 11, Gemini's 12, Qwen's 13 and the DeepSeek, Kimi and Grok models each take a measured subset of levels, and
offis refused where the probe showed it (for examplegpt-6.1-sol,gpt-6-astra,gpt-5,gpt-5-mini,gpt-5-nanoando3);deepseek-flashanddeepseek-v4-prorefuseminimal,mediumandxhigh, because DeepSeek runs them aslow,highandhigh; Qwen takesautoand aBudget(1..=32768)on 11 models; no Anthropic or Gemini model takes an explicitBudget; the per-model table is in the Reasoning Effort guide. The shared conformance suite grows from 21 to 24 cases. - BREAKING (
paladin-ai-core):TraceEvent::NodeFinishedgains the fieldsreasoning_requested: Option<String>andreasoning_effective: Option<String>(REASON-05, D-00b, D-11; plan 09;MIGRATION.md10.2). Both are serde-defaulted and skipped whenNone; only a constructing struct-variant literal or an exhaustive destructure ofNodeFinishedbreaks. Migration: add reasoning_requested: None and reasoning_effective: None to the literal, or match with a rest pattern. There is no deprecated alias. - Node-cache keys of Paladin nodes include the agent's reasoning effort when one is set (REASON-01; plan 09): the fingerprint appends the agent's
reasoning_effortandreasoning_fallbackonly when set, so an agent that sets neither composes exactly the key it did before, no existing cache entry is invalidated andNODE_CACHE_KEY_VERSIONstaysk1. A provider-level default (llm.<provider>.reasoning_effort) is not part of the key; changing it needs a new cache namespace or a version bump. - BREAKING (
paladin-ai):AgentDefinitiongains the fieldsreasoning_effortandreasoning_fallback(REASON-01, D-00b, D-00e; plan 08;MIGRATION.md10.2). Both are#[serde(default)], so every existing YAML file loads unchanged; only a full struct literal of the definition breaks. Migration: addreasoning_effort: None, reasoning_fallback: Noneto the literal. There is no deprecated alias. - BREAKING (
paladin-ai-core):PaladinDatagains the fieldsreasoning_effortandreasoning_fallback(REASON-01, D-00b, D-00e; plan 07;MIGRATION.md10.2). Both are serde-defaulted, skipped whenNoneandNoneinDefault, so a stored Citadel snapshot still loads and a Paladin that sets neither serialises byte-identically; only full struct literals break. Migration: addreasoning_effort: None, reasoning_fallback: Noneto the literal, or spread..PaladinData::default(). There is no deprecated alias. - BREAKING (
paladin-llm):CompatRequestParametersgains a reasoning field of type ReasoningWire, and CompatRequestParameters::all() becomes all(reasoning: ReasoningWire) (REASON-02, D-00b; plan 04;MIGRATION.md10.2). The type has noDefaultby design. Migration: addreasoning: ReasoningWire::Unsupportedto the literal, or pass the wire toall(..)(CompatRequestParameters::all(ReasoningWire::Unsupported)keeps today's behaviour). There is no deprecated alias.
Phase 50.1 — Consumer-Surface Readiness for v0.11.0
- A base URL must start with
http://orhttps://on all nine adapters (WR-03; review fix5ebd315b;MIGRATION.md10.8): the OpenAI, DeepSeek, Kimi, Qwen, Grok, Ollama and OpenAI-compatible configs used to accept anybase_urlthat began withhttp, sohttpx://hostpassedvalidate()and failed later at the HTTP layer; they now apply the full-prefix check Anthropic and Gemini already did, so such a value failsAdapter::newwith"Base URL must start with http:// or https://"(previously"... with http or https").
Phase 50 — Provider Parity & Configuration-Driven Model Swap
- Vendor usage totals are reconciled on every adapter (G-50-5a; plans 50-23 and 50-24;
MIGRATION.md10.1 M-B-20): the OpenAI, DeepSeek and Gemini adapters parse the vendor's own total (total_tokens,totalTokenCount) on both paths, compare it with the mappedprompt_tokens + completion_tokensand also check reasoning and cache containment; a finding is recorded underpaladin.usage_unreconciledand logged once per model, as the five compat presets have done since plan 50-21. The total is never copied:total_tokensstaysprompt + completion(D-02). The total is optional on the wire, so a usage object without one now parses on these three instead of failing; the Anthropic adapter reports no vendor total and runs the containment checks only. - Plan 50-24 applied an operator's live usage probe of 2026-10-18 to every provider's reading of reasoning tokens; no reading changed. Grok's reasoning-beside-completion fold is measured on
grok-4.3,grok-4.5andgrok-4.6(xAI's billedcost_in_usd_ticksmatches to the tick); DeepSeek, Kimi and Qwen (qwen3.8-flash) were measured reasoning-inside-completion; OpenAI,qwen-plus,gemini-3.5-flash-liteand Anthropic stayed unsettled and keep their documented readings. llm.<provider>blocks now take effect: the server builds every agent throughcreate_from_config(PARITY-03; plan 50-10; D-12, D-13, D-02;MIGRATION.md10.1 M-B-15 and 10.5).build_agent_registry*,paladin_port_from_settings*andFacadeProvisionerresolve each agent's ownprovider(elsellm.default_provider) andmodelagainst the provider's block, then the provider's environment variables, then the adapter default. A block'sdefault_temperatureis now passed to its agents as an explicit value when the agent sets none; a model that does not accept it refuses the agent at startup naming the provider, the model,temperatureand the key that set it.validate_configjudges a provider by its block or its environment, so a provider configured only in YAML starts, and a registered provider with no credential fails naming the agent, the provider, the model and the variable (HostBuildError::Provider;HostBuildError::UnknownProvideris kept for a name this build does not register).LlmConfig::validateno longer requires the default provider's block to exist. The agent'stimeout_secondsis still the per-agent execution timeout, never the provider's HTTP timeout.api_keymay be empty in anllm.<provider>block (plan 50-09; A1-50-09):LlmConfig::validateno longer rejects an empty key, and a block with noapi_keykey deserializes (it used to fail the whole file); the provider's environment variable fills it, and a key still missing at construction is reported then, naming the provider, the model and the variable.LlmProviderConfigprints its key as<redacted>fromDebug.- The infallible
From<&LlmProviderConfig>conversions now run the same layering with no environment: the Anthropic default model isclaude-haiku-5-5(was the retired October-2024 Sonnet id) with the adapter's 60 s timeout (was 300 s), the DeepSeek default model isdeepseek-flash(wasdeepseek-chat) with 300 s as a reasoning model (was 60 s), and a${NAME}key is never carried through as the credential. - Reasoning models default to a 300 s timeout on the direct config path, and an adapter-wide
context_windowoverrides seeded windows (D-13; plan 50-08):KimiConfig,QwenConfig,GrokConfig,DeepSeekConfig,GeminiConfigandAnthropicConfigdefaulttimeout_secondsto 300 when the configured model is a seeded reasoning model and no<PROVIDER>_TIMEOUT_SECONDSis set (so the default timeout of Kimi, Qwen, Grok, DeepSeek and Gemini rises from 60 s); an explicit value always wins. Acontext_windowset on a config replaces the adapter's declared window, the fallback window and every seeded model's window; a per-model override still beats it.LlmProviderConfig.max_retriesis now honoured by the Kimi, Qwen, Grok, Ollama, OpenAI-compatible, Anthropic, DeepSeek and Gemini conversions (it was ignored for them). - Explicit sampling values are checked against the model's profile; auto and default values are omitted visibly (PARITY-01, PARITY-02, PARITY-05; plans 50-03, 50-05; ADR-0061, amending ADR-0004;
MIGRATION.md10.1, M-B-13): an explicit.temperature()or.add_stop_word()the profile does not admit now failsbuild()with aConfigurationErrornaming the provider, the model and the parameter. Anauto_temperatureresult the profile does not admit is no longer refused (ADR-0004 A2 is superseded): it logs a warning and is omitted at the wire, recorded aspaladin.params_omitted. Amax_tokensabove a model's profiled maximum output is refused by name before anything is sent, never clamped; the dropped-parameter log moved fromdebug!towarn!. - Per-provider effects: Kimi sends no
temperature,top_por penalty on any model (before this phase it droppedtemperatureandtop_punconditionally and sent both penalties); stop sequences now reach the wire in declared order on the compat presets (except Grok, which refuses them at build), on OpenAI (stop, at most 4; more are omitted whole and recorded, never truncated), DeepSeek (stop) and Anthropic (stop_sequences); a JSON-schemaresponse_formatis still sent as a plain JSON object and the downgrade is now recorded. OpenAI no longer sends a fabricatedtemperatureof 0.7 ortop_pof 1.0 when the caller set none (max_tokenskeeps its documented default of 4096); Anthropic's validate-then-discard is gone, so an explicit temperature on an Anthropic agent is refused at build by name and a direct call's value is omitted and recorded. Thepaladin-llmpublic surface is unchanged. - Empty output has one meaning on seven providers (PARITY-05; plan 50-12; D-09, D-11a;
MIGRATION.md10.1 M-B-16): text that is empty after trimming is nowLlmError::EmptyCompletionon any finish reason, buffered and streamed, for the five OpenAI-compatible presets, OpenAI and DeepSeek (it wasOk("")unless the finish reason waslength, and a stream with no text now ends inErr(EmptyCompletion)instead of anOkterminal chunk). Acontent_filteror function-call finish staysOk, a refusal wins over partial text. TheDisplayofEmptyCompletionno longer appends max-tokens advice to every message: it readsEmpty completion: {payload}. OpenAItool_callsnow maps toFinishReason::FunctionCall. - Anthropic and Gemini join the response rules (PARITY-05; plan 50-13; D-09, D-11, D-11a;
MIGRATION.md10.1 M-B-17): an Anthropicstop_reason: "refusal"is nowLlmError::Refusal(it was reported as anEmptyCompletionwith max-tokens advice), buffered and streamed, withdetailbuilt fromstop_detailsand redacted before it is bounded. Every Anthropic stop reason has a name (tool_useisFinishReason::FunctionCall,pause_turnisError("pause_turn"),model_context_window_exceededisLength, anything else isError("unknown: <value>")), and a streamed response reports its real stop reason instead of an unconditionalStop. thinkingtext reachesLlmResponse.reasoningandStreamingResponse.reasoning. An Anthropic or Gemini stream with no text ends inErr(EmptyCompletion); GeminiSTOPwith empty text isEmptyCompletionon both paths, and GeminiRECITATION,BLOCKLIST,PROHIBITED_CONTENTandSPIImap toFinishReason::ContentFilterlikeSAFETY. No compat preset gains a usage-cap mapping: none of their quota bodies was captured, and the owner accepted each as unverified (plan 50-17).
Phase 48 — Base Entity Rename
- The base version family is renamed to the Entity vocabulary (ENTITY-03; ADR-0060; the Phase 48 rows in
MIGRATION.md10.2):base::service::node_version_servicebecomesbase::service::entity_version_service, andNodeVersion,NodeVersionMetadata,NodeVersionRepository,NodeVersionServiceandInMemoryNodeVersionRepositorybecomeEntityVersion,EntityVersionMetadata,EntityVersionRepository,EntityVersionServiceandInMemoryEntityVersionRepository.VersioningError::NodeNotFoundbecomesEntityNotFound; the version record'snode_id/node_datafields becomeentity_id/entity_data, andVersioningConfig.max_versions_per_nodebecomesmax_versions_per_entity. EntityVersioncarries serde read-aliases for the v0.10 keys, so a version record written by v0.10 still loads; new writes emit onlyentity_id/entity_data..project/current-exports.txtchanges by 14 lines, all of themContentItemService/ContentItemServiceError; the unusedField,CollectionType/Item,element,FieldVersionServiceandCollectionVersionServicestay (D-05), only their import path moved.- Persisted entity JSON is written with new keys and still reads the old ones (ENTITY-01; ADR-0060;
MIGRATION.md10.1 M-B-12 and 10.4): the entity payload keynodebecomesdata,ContentItemandPromptItemstore their entity underentityinstead ofnode, and a version record storesentity_id/entity_data. v0.11 reads both spellings and writes only the new ones; a document carrying both spellings of one key is rejected. - This is one-way: a v0.10 binary cannot read what v0.11 wrote, and it cannot read a Citadel snapshot whose Paladin status is
Reasoning. Back up before upgrading, upgrade readers and queue consumers before writers, and downgrade only by restoring the backup. The read-aliases stay for the whole 0.x line; removing them needs its own ADR. "Node" means a WarGraph or Campaign vertex; "Entity" means the base wrapper.
Phase 43 — Rate Pacing
LlmError::RateLimitExceededis now a struct variant{ retry_after, hints }, marked#[non_exhaustive](PACE-01; plan 43-02;MIGRATION.mdsection 10.2). Construct it withLlmError::rate_limited(None)(orrate_limited(Some(delay))/rate_limited_with_hints(hints)), and match it withLlmError::RateLimitExceeded { .. }. ItsDisplaytext and itsTransientclassification are unchanged.- The Anthropic adapter now surfaces a 429 on its first attempt instead of retrying it inside its own loop, matching OpenAI (D-02; PACE-02; plan 43-03); its network and 5xx retries are unchanged. An account-level quota or spend-cap 429 (OpenAI
insufficient_quota, Anthropicenforced_spend_limit_reached) is now reported as the permanentLlmError::UsageLimitExceededinstead ofLlmError::RateLimitExceeded(MIGRATION.mdsection 10.1). - The compat engine (Kimi, Qwen, Grok, Ollama and OpenAI-compatible presets), the DeepSeek adapter and the Gemini adapter now also surface a 429 on their first attempt, carrying the provider's
Retry-After,retry-after-msand genericx-ratelimit-*values on the error, on both the buffered and streaming paths (D-02; PACE-01, PACE-02; plan 43-04;MIGRATION.mdsection 10.1); their network, timeout and 5xx retries are unchanged. The shared conformance suite gained a tenth case,rate_limit_is_surfaced_once_with_its_retry_delay.
Phase 42 — Mid-Run Halt & SSE Terminal Status
- The output of an agent run that ends on the Treasurer's derived allowance figure now carries
[budget] Allowance reached — this response is partial; the run was halted.instead of the operator-budget text; an operatortoken_budgetstop keeps its existing text (review IN-3). - The operator
agent_runtime.token_budgetnow takes effect on the HTTP agent routes (ALLOW-05; plan 42-08): every per-agentPaladinExecutionServiceinstalls the oneTokenBudget, soagent_runtime.token_budget(documented but, for agents served over HTTP, never applied before) caps their runs whenenabled, and the budget derived from a caller's allowance composes with it (the tightest wins, a tie goes to the allowance). A deployment with the operator budget disabled (the default) and no allowance is unchanged.FacadeProvisioner::with_token_budgetcarries it for runtime provisioning;MIGRATION.md§10.6. - The SSE
donesayscancelledfor a caller cancel, and a worker drain emits no terminal event (PLAT-09; plan 42-06): a run a caller cancelled streameddonewithstatus: "halted"whileGET /v1/runs/{id}saidcancelled; the stream now agrees with the row, through a newRunFinishStatus::Cancelled(the enum is now#[non_exhaustive]). A graceful worker shutdown no longer emits ahalteddonefor a run that is stillrunningand requeued. A client that matchedhaltedto detect a cancel must also matchcancelled. SeeMIGRATION.md§10.2 and §10.6.
Phase 41 — Admission-Time Allowance Enforcement
SubmitRun.attributed_toandCreateRunSchedule.created_by(plan 41-05) are newOption<RunAttribution>fields on plain public structs, so a caller that buildsSubmitRun { .. }orCreateRunSchedule { .. }with a struct literal must add the field (attributed_to: None/created_by: Nonekeeps today's behaviour).RunSchedulegainscreated_byandwith_created_by, andSkipReasongainsAllowanceExhausted; both types were already#[non_exhaustive]. SeeMIGRATION.md§10.2.treasurer:now rejects unknown keys (plan 41-03):TreasurerConfigand everytreasurer.allowancestruct fail the config load on an unrecognised key (for exampleallowence:orperid:) instead of silently enforcing nothing; the allowance keys were unreleased, so no published configuration is affected.
Phase 39 — Spend Ledger
GET /runs/{run_id}andGET /runscarry a ledger-derivedcost: Option<CostDto>, computed from TreasuryLedgerPort::spend at read time, never persisted on the run row, andnullwhen no ledger backend is configured, the run has no settled spend, or its settlements span more than one currency (D-10; LEDGR-04; plan 39-06;MIGRATION.md§10.2). The agent execute response (ExecuteResponse.cost) carriesPaladinResult.costthe same way.- Every ledger row the phase's production writers create carries the documented
LedgerScope::unattributed()sentinel scope until Phase 40 records a run's real submitting principal (D-01).
Phase 38 — Design Seams & Pricing/Cost Producer
- Heralds print a run's cost as four decimals followed by its ISO currency code (e.g.
0.0450 USD), never a hard-coded$(D-04; plans 38-02, 38-05). The table herald'sfinalize_streamrenders the run's realExecutionMetadatainstead of four hard-coded placeholder rows that ignored the argument, and the agent loop's final streamed chunk always carries an execution metadata payload, whether or not the run was priced. LlmResponse,PaladinResult,TraceEvent::NodeFinished/RunFinished,StreamingResponseandChunkMetadataeach gain acost: Option<Cost>field besideusage; a full struct literal of the first three needscost: Noneadded (MIGRATION.md§10.2; plans 38-02, 38-04, 38-06, 38-07).
Phase 40 — Tenant Identity & Run-Read Scoping
- Ledger settlements on both production paths are attributed to the submitting principal's tenant and API key (
LedgerScope::from_attribution) instead of the Phase 39unattributedsentinel, now written only for a run with no recorded principal; the007ledger schema,TreasuryLedgerPortand its adapters are unchanged (TENANT-02; plan 40-04). - The run read routes are tenant-scoped:
GET /runsreturns only the caller's tenant's runs (Admin: all), and every/runs/{run_id}*route answers404for another tenant's run;GET /runs/{run_id}/stream,POST /runs/{run_id}/cancelandGET /runs/{run_id}/webhook-deliveriesalso answer501when no run store is configured, asGET /runs/{run_id}already did (PLAT-07; plans 40-01, 40-05;MIGRATION.md§10.6).WINDOWS.mdrow 32 (WR-03) is closed; the/v1/threads/*routes remain unscoped and are tracked as a new open row.
Phase 45 — RustFS Swap & Platform/Observability Deviations
- The dev/test/CI and reference object store is RustFS (STORE-01, STORE-03; ADR-0055): every live configuration now runs the pinned
rustfs/rustfs:1.0.0(CI, the compose files, the devcontainer, the Kubernetes manifest and the contract suite's local mode). The store-side variables are nowRUSTFS_ACCESS_KEYandRUSTFS_SECRET_KEY; the application-sideAPP_MINIO_*,MINIO_*andTEST_MINIO_*names are unchanged, but a local.envmust rename its two store-side variables. - Compose services are
rustfsandrustfs-test, and the dev and devcontainer volumes arerustfs_dataandrustfs-data; old MinIO dev volumes are not migrated (remove them withdocker volume rm).k8s/minio.yamlis renamedk8s/rustfs.yaml(Servicepaladin-rustfs, labelapp: rustfs); anyone applying the old path must switch, and it stays a single-node reference manifest, not a production store. TheFileStoragePortcontract suite now runs in the Integration Tests and Docker Integration Tests jobs, each checking that at least 11 cases ran. LogTraceSinkskips JSON serialisation when thepaladin::tracetarget is filtered and reuses a per-thread buffer when it is enabled;CompositeSinkhands the record to its last child without cloning (OBS-05; plan 45-03; public API unchanged).
Phase 44 — Legacy Clean-Break Removal
- Battalion fault handling moves onto
BattalionConfig.aegis(LEGACY-01, LEGACY-02; ADR-0059;MIGRATION.md10.1 M-B-06..M-B-11 and the Phase 44 10.2 rows):BattalionConfiggains#[serde(default)] pub aegis: Aegis, thewith_aegisbuilder and a purevalidate_aegischeck run at each service's entry. Formation, Phalanx, Campaign and the Conclave bound every attempt byaegis.timeout(the tighter ofrun_timeoutandidle_timeout) through one crate-private runner,aegis_attempt: there is no whole-Battalion clock any more, so a ten-step Formation may run ten times the bound.BattalionConfig::newarms no timeout. - Retries now follow
aegis.retry, wheremax_attemptscounts total attempts (a v0.10 policy of N retries ismax_attempts: N + 1) and the defaultRetryPredicate::TransientOnlyno longer retriesUnknownfailures; continuing past a failed Paladin isaegis.on_error = Some(ErrorHandlerSpec::Absorb { .. })(Formation and Phalanx; a Formation that continues passes an empty input to the next Paladin; Campaign has no continue mode and warns once). A timed-out final attempt is aBattalionError::Nodewith aTimeoutsource (Formation, Campaign) or an entry insideAggregationError(Phalanx). BattalionResult.node_errorscarries the structuredNodeError, a one-way door (LEGACY-01; ADR-0059;MIGRATION.md10.1 M-B-08, section 10.4 and the Phase 44 10.2 rows): the element changes from the v0.10{ node_name, error }summary to{ node_id, attempt, transience, source }(sourceis an externally taggedLlm,PaladinorTimeoutobject). A stored v0.10BattalionResultdocument whosenode_errorsis non-empty no longer deserializes; an empty or absent array still does, and there is no compatibility deserializer. The JSON herald emits the new shape, and the Markdown and Table heralds render attempt, transience and source with no provider or HTTP status.- The Conclave and the circuit breaker classify by
transience(), and the Conclave uses the engine backoff (LEGACY-03; ADR-0059;MIGRATION.md10.1 M-B-09, M-B-10): the Conclave retries an expert only whenPaladinError::transience()isTransient(never by message text;Unknownis not retried), with the engine'sbackoff_delayshape in place of its private 1, 2, 4, 8, 16 s ladder;CircuitBreakerOpen, Garrison storage and tokenization failures and Arsenal timeout and transport failures are now retried. The circuit breaker countsTransientfailures only, so aPermanentfailure (a 401 from a bad key) or anUnknownone no longer trips it. - The Commander's defaults (LEGACY-01, LEGACY-02; ADR-0059;
MIGRATION.md10.1 M-B-06, M-B-09, M-B-11): a bareCommanderBuilderbounds each attempt at 300 s; the Commander no longer wrapsexecute()in a whole-run timeout, so ChainOfCommand and Grove are unbounded unless the caller wrapsCommander::execute(); its Conclave bridge derivesretry_attempts = max_attempts - 1fromaegis.retry; its default Maneuver strategy ismaneuver::ErrorStrategy::FailFast(setCommanderBuilder::error_strategyto keepContinueParallel). The CLI keeps itstimeout_secondsYAML keys, which now bound each attempt.
Removed
-
The
mcbootstrap: the compose init containers, the CI client-install and bucket-setup steps
and the checksum-verified client download. The adapter creates its own bucket. -
The MinIO server and client images from every live configuration.
-
The anonymous-public read policy on
paladin-filesand the unusedpaladin-analysis,
paladin-reportsandpaladin-backupsbuckets from the dev compose. -
The
testcontainers-modulesdev-dependency. -
The
.github/actionlint.yamlservices.commandsuppression, which was dead configuration. -
The legacy Battalion fault-handling surfaces, a clean break with no shim (Phase 44; LEGACY-01,
LEGACY-02, LEGACY-03; ADR-0059; LEGACY-01..04; see MIGRATION.md 10.1 M-B-06..M-B-11 and the Phase
44 10.2 rows). Each removal names its replacement:- The legacy Battalion
RetryPolicy,ErrorStrategyandNodeErrortypes inpaladin-core
(the same-named AegisRetryPolicy, the Maneuver'sErrorStrategyand the structured
node_error::NodeErrorare different items and stay). Replacements:BattalionConfig.aegis
(retry,on_error) and the structuredNodeError. BattalionConfig'stimeout_seconds,retry_policyanderror_strategyfields and its
with_timeout,with_retry_policyandwith_error_strategybuilders. Replacement:
BattalionConfig::with_aegis(Aegis { timeout, retry, on_error, .. }); a v0.10 document
carrying a removed key is refused at load, naming the key and itsaegis.*replacement
(Phase 46, D-23; MIGRATION.md 10.1 M-B-06), and a document without them loads withaegis
asAegis::default().BattalionError::TimeoutandConclaveError::Timeout. Replacement: a per-attempt
NodeErrorSource::TimeoutonBattalionError::Node, orConclaveError::AggregatorFailedfor
the aggregator.ConclaveConfig::timeout_secondsandConclaveConfig::with_timeout. Replacement:
BattalionConfig.aegis.timeout, applied to every expert and aggregator attempt.PaladinError::LlmError,PaladinError::is_retryableandPaladinError::is_terminal.
Replacement:PaladinError::LlmFailurebuilt byllm_failure::to_paladin_error, and
PaladinError::transience(), the single classification source (transience() == Transience::Permanentis the closest read foris_terminal; there is no equivalent for the
old retry set).- The
paladin_battalion::retrymodule (should_retry,calculate_retry_delay) and its facade
re-exportpaladin::application::services::battalion::retry. Replacement:aegis.retrywith
engine::retry::backoff_delay. The API baseline.project/current-exports.txtloses that one
line.
- The legacy Battalion
-
The base entity wrapper's old name, a clean break with no shim (Phase 48; ENTITY-01..05;
ADR-0060; seeMIGRATION.md10.1 M-B-12 and the Phase 48 10.2 rows). Each removal names its
replacement:- The generic base wrapper
Node<T>and its modulebase::entity::node. Replacement:
paladin_core::base::entity::Entity<T>, with the payload field renamed fromnodetodata. - The base version family
NodeVersion,NodeVersionMetadata,NodeVersionRepository,
NodeVersionServiceandInMemoryNodeVersionRepository, and the module
base::service::node_version_service. Replacement: theEntityVersion*family in
base::service::entity_version_service(described under Changed).VersionHistory,
VersionComparison,VersioningConfig,ChangeType,HashAlgorithmandVersioningError
keep their names and moved with the module. - The Citadel module's placeholder
PaladinDataandPaladinStatusand its duplicatePaladin
alias. Replacement: the real types inplatform::container::paladin;PaladinState.paladinis
now the realPaladin. The realPaladinStatusaddsReasoning. ContentItem.nodeandPromptItem.node. Replacement:.entity, so a content item's payload is
read asitem.entity.data.NodeId,NodeSpec,StateNode, the structuredNodeErrorand every other graph item are
different items and are unchanged.Field,CollectionType/Item,element,
FieldVersionServiceandCollectionVersionServiceare retained pending review and are not
removed.
- The generic base wrapper
-
Default models, the Anthropic and DeepSeek model lists, and reported windows follow the
dated registry (PARITY-04; Phase 50 plan 50-06; D-05/D-08;MIGRATION.mdrow M-B-14).
With no model configured the adapters now default to the operator-identified current model:
Anthropicclaude-haiku-5-5(was the retiredclaude-3-5-sonnet-20241022), DeepSeek
deepseek-flash(wasdeepseek-chat), Kimikimi-k2.6, Qwenqwen3.8-flash, Grok
grok-4.3, Geminigemini-3.5-flash-lite.get_capabilities().max_context_tokensis now the
default model's seeded window (Anthropic 1,000,000; DeepSeek 1,048,576, was 64,000; Kimi
262,144; Qwen and Grok 1,000,000).AnthropicAdapter::get_available_modelsand
validate_modelserve the Anthropic table (the retired claude-3 and claude-2 lists are gone)
andDeepSeekAdapter::get_available_modelsservesdeepseek-flash. Pin a model explicitly to
keep an earlier one.
Fixed
-
Release-doc currency (CURR-22; Phase 46 plan 46-01, D-11/D-12/D-13). CHANGELOG
[0.10.0]and
[0.10.1]heading dates (root and all eleven crate changelogs) now equal their tag commit dates,
2026-09-18 and 2026-09-21; the release docs name the twelve publishable crates and carry
paladin-eval's Trusted Publishing and Credential History rows. -
Reasoning effort gap closure (REASON-01, REASON-04, REASON-05; Phase 51 plans 51-15 to 51-19).
Single-agent runs now carryreasoning_requested/reasoning_effectiveonNodeFinished
(51-15, gap G-1). The agent host took the provider block'sreasoning_fallbackbefore the
agent's own at load, the reverse of the send-time order; the agent's now wins (51-16, G-2).
A provider-levelreasoning_fallbackwas inert at build and in the chain pre-flight, so an
agent effort that the fallback would substitute at send time was refused at build (51-17 to
51-19, G-2, review WR-01). -
Billed failures, hopped-past fallback bills and failed engine attempts are priced and settled
(G-50-5a, PARITY-05; Phase 50 plans 50-26 to 50-30;MIGRATION.md10.1 M-B-21). A vendor
bills a call that ends in an empty completion or a refusal, but Paladin used to drop that usage
with the error, so the Treasurer under-charged. Now:- every adapter, buffered and streamed, puts the call's usage on the error
(LlmError::billed_usage()), on the served model, else the request model; no usage object
means no bill (plans 50-26 and 50-27); PricingLlmAdapterprices that bill like a success, including an error returned by the call
that opens a stream (plans 50-27 and 50-30);PaladinExecutionServicefolds it into the run's usage and cost, settles it to the treasury
ledger under the call's ordinal, and records it in the attempt spend scope, buffered and
streamed (plans 50-28 and 50-29);- a fallback hop moved past after a billed refusal (
hop_on_refusal(true)) is priced against its
own model and settled in its call's one ledger row with a per-model breakdown. This holds
whether a later hop serves or the chain is exhausted, and on every exit of a stream, a dropped
stream included; the stream's terminal chunk folds the hop bills into its cost, and into its
usage when the provider reported the served call's usage (plan 50-30); - a failed or timed-out Battalion attempt reports the spend it recorded on
NodeFinished, and
the superstep settle charges it once; a structured attempt counts every repair call
(plan 50-29); - a hopped-past bill now reaches the attempt's spend at its hop, priced against its own model,
so a Battalion attempt cut off by its deadline or idle bound while a later hop is still in
flight is charged for it (review WR-03, plan 50-32).
total_tokensstaysprompt_tokens + completion_tokens(D-02). A bill on an unpriced model
keepscost: Noneand settles nothing. Proved end to end through real adapters by the
provider_swap_matrixcasebilled_failures_and_hopped_refusals_reach_the_ledger(plan 50-31).
- every adapter, buffered and streamed, puts the call's usage on the error
-
Gemini tool-use prompt tokens and Anthropic vision cache tokens are counted (G-50-5a; Phase 50
plan 50-23;MIGRATION.md10.1 M-B-20).- Gemini reports
toolUsePromptTokenCountbesidepromptTokenCount, and itstotalTokenCount
includes it. The Gemini adapter dropped it, so it reported, and the Treasurer priced, fewer
prompt tokens than Gemini bills. The adapter now folds it intoprompt_tokens(saturating).
Repro: prompt 100, candidates 20, tool use 300, thoughts 10 and vendor total 430 used to map
to prompt 100, total 130. It now maps to prompt 400, total 430. The path is latent today,
because the adapter sends no tools. - The Anthropic vision path ignored
cache_read_input_tokensandcache_creation_input_tokens.
It now folds them intoprompt_tokensas the chat path does, on both vision surfaces.
- Gemini reports
-
Grok reasoning tokens are counted and priced (G-50-5a; Phase 50 plan 50-21;
MIGRATION.md
10.1 M-B-19). xAI reportscompletion_tokens_details.reasoning_tokensbeside
completion_tokens(its total is prompt + completion + reasoning), while the shared compat
engine read every preset with OpenAI's inclusive contract, so every Grok reasoning call dropped
its reasoning tokens fromtotal_tokens, reported more reasoning than completion, and was
priced by the Treasurer at about half what xAI bills. The Grok adapter now folds the reasoning
intocompletion_tokens(and so intototal_tokens), which is priced at the completion rate
(or the row's reasoning rate). Repro at $1/M input and $10/M output for prompt 663, completion
50, reasoning 124, vendor total 837: 1_163_000 nanos before, 2_403_000 nanos now, the amount xAI
bills.total_tokensis stillprompt_tokens + completion_tokens(D-02). xAI's
reasoning-exclusivecompletion_tokenswas live-measured on 2026-10-18 (plans 50-22 and 50-24)
ongrok-4.3,grok-4.5andgrok-4.6, and xAI's billedcost_in_usd_ticksmatched
reasoning billed at the completion rate. A future model that reads differently would be
flagged on every reasoning call by the reconciliation below. Kimi, Qwen, Ollama and OpenAI-compatible keep the inclusive reading. -
The OpenAI adapter sends
max_completion_tokens, sogpt-5.4-nanono longer returns HTTP 400
(G-50-5b; Phase 50 plan 50-19;MIGRATION.md10.1 M-B-18). The adapter sent the output limit
asmax_tokens, whichgpt-5.4-nano(the first seeded OpenAI model) rejects with
unsupported_parameter, and the defaults always set a limit, so every request to it failed.
Every OpenAI chat-completions body (buffered, streamed and everyresponse_format) and the
vision request now carry the limit asmax_completion_tokensfor every model. Measured
accepting that key:gpt-5.4-nanoandgpt-4o-minionly (2026-10-15); other OpenAI ids and
non-OpenAI servers behindOPENAI_BASE_URLare unmeasured. The Paladin-side names
(OpenAIConfig.max_tokens,PromptParameters.max_tokens) and the DeepSeek and compat presets'
max_tokenswire key are unchanged.live_vendor_smokenow drivesgpt-5.4-nanoby default. -
The OpenAI adapter no longer sends an empty user message, and all nine adapters build the user
turn the same way (PARITY-08; Phase 50 plan 50-01; handoff B-4). The OpenAI adapter built the
user message fromUserPrompt::contextonly, whilePaladinExecutionServicefills
UserPrompt::queryand leavescontextunset, so every OpenAI request on the main execution
path went out withcontent: "". The other adapters read the query only and dropped the
context. All nine now call one shared rule: the query then the context, each only when
non-empty after trimming, joined by one blank line and otherwise sent unchanged. A query-only
prompt is byte-identical to before on the eight adapters that already sent it. Public API
unchanged. -
A run whose superstep charges cannot be written to the treasury ledger no longer spends
unmetered while the guard's balance reads keep succeeding (Phase 42 review WR-4). The engine now
tells an attachedSpendGuardwhen a boundary settlement was lost (a ledger error or a currency
mismatch) through the new defaultedSpendGuard::note_unsettled_spend, andTreasurerSpendGuard
answersHalt(ledger_unavailable)at its next check for a principal with an applicable ceiling.
A principal with no allowance, a run with no guard and a guard that does not override the method
are unchanged: settlement itself still never fails, retries or halts a run. New public surface:
SpendGuard::note_unsettled_spend(paladin-ports; defaulted, so not breaking). -
The mid-run boundary guard no longer drops a run's
allowance_warningafter one transient
notice-store error: a claim enters the in-run memo only once the store has answeredRecorded
orAlreadyRecorded, so a failed write is retried at the next boundary (Phase 42 review WR-3).
A halt notice lost to a store error is still not retried by the same run, which ends at once. -
SSE replay of a run on a thread that already hosts another run (the documented fork recovery
path) no longer ends on the prior run'sRunFinished: replayed records stamped with a different
run's id are skipped, and a persistedRunFinished { Halted }is skipped when the run row is
CompletedorFailed(a drained worker's halt record for a run that was requeued and ran on)
(Phase 42 review WR-2). A terminal record that has a later record of the same run behind it is
also skipped, whatever the run row says, so a drained worker's stale reasonlessHaltedrecord
no longer cuts the replay of a run that was requeued and endedHaltedorCancelled(Phase 42
review WR-5). -
A second run on one thread (the documented resume-by-fork path, or a requeued drained run) no
longer loses the trace records whoseseqan earlier run already used: the worker seeds the run's
trace dispatcher from the thread's largest persistedseq(RunTracePort::max_seq,
TraceDispatcher::with_seq_origin), so the later run'sRunStartedthroughRunFinishedare all
persisted. Runs already on a thread keep their records; only new dispatches are numbered after
them (Phase 42 review WR-6). -
PLAT-08 /
WINDOWS.mdrow 31 (WR-02): runs against a code-registered agent now stream live SSE
(node_started,node_finished, thendoneorerror) through the samemap_trace_event
mapping a graph run uses, persist their trace rows and feed the OTel and herald sinks when
those are wired, and enqueuecompleted/failedwebhook deliveries through the same
SSRF-guarded delivery service. A graph run failing before its engine emitsRunStarted
(including a corrupt fork) now enqueues itsfaileddelivery too. An agent failure'serror
event carriesmessage: null, exactly like a graph engine failure — read the text from
GET /runs/{run_id}. Public API unchanged. -
The file-storage adapter's bucket bootstrap is path-style: virtual-hosted bucket creation failed
on RustFS, and on MinIO without domain configuration.copy_fileno longer double-prefixes the
bucket, and the integration harness no longer panics on a hostname endpoint or on a second
logger initialisation (STORE-01, STORE-02; Phase 45 plan 45-01). -
paladin-servernow honours thetrace:config section for Platform API runs:build_run_api
handstraceto the run worker pool and, whentrace.persististrue, opens the trace store
on the run store's own backend, so run traces reachrun_traces. Previously the pool ran on
TraceConfig::default()whatever was configured andtrace.persist: truewrote nothing (found
by the Phase 41 operator UAT). Public API unchanged. -
A
run_startedtrace record of a Platform API run is written with onerun_idkey. It used to
carry the key twice (the record envelope and the event), so the row was stored but
RunTracePort::readfailed on it with "duplicate fieldrun_id" on SQLite. Rows already
written with the doubled key still read back.TraceEvent::RunStartedno longer serializes
run_idon its own; inside aTraceRecordthe value is unchanged. Public API unchanged.
Documentation
- Treasurer architecture page and one configuration reference (CURR-23; Phase 46 plan 46-07,
D-07..D-10). The book gainsdocs/src/architecture/treasurer.md(the price table, the cost
ledger, per-tenant and per-key allowances, and Cadence), andconfiguration.mdcarries a single
## Treasurerblock covering the key reference, pricing, allowances (including the warn
threshold) and rate pacing (Cadence). MIGRATION.mdis a two-part guide, with section 10 "Upgrading from v0.10.1 to v0.11.0" (CURR-23;
Phase 46 plans 46-10, 46-11, D-01..D-03). Section 10 (behavioral changes M-B-05 onward, the
section 10.2 register, the section 10.8 operator checklist and library-consumer table) sits above
the kept v0.10 record in section 9, and is frozen on the downstream consumer's dogfood report
againste28e43b5.MIGRATION.mdsection 10 is the authoritative v0.11.0 upgrade record.- The Upgrading page leads with v0.10.1 to v0.11.0 (CURR-23; Phase 46 plan 46-13, D-05).
docs/src/api-reference/upgrading.mdopens with a condensed behavioral-change table, the
library-consumer index by topic and the operator checklist, each linkingMIGRATION.md
section 10 by anchor; the v0.9.x to v0.10.0 orientation stays below it, and the Migration Guide
points at the new record. - Switching providers: a dated DeepSeek listing note (G-4; Phase 46 plan 46-13, D-03). The
page records, as the downstream consumer's live/modelscheck of 2026-10-24 (not re-run by
Paladin), that DeepSeek listsdeepseek-flash(seeded) and does not listdeepseek-v4-flash,
which is unseeded and runs on the adapter-wide fallback profile.
Security
-
Governed exception for
zip 3.0.0(RUSTSEC-2026-0367/0368/0369; Phase 50 seal). The three
extraction advisories published 2026-10-09/10 (SUID/SGID bits, symlink targets outside the
destination, permissions through symlinks) reach this workspace only as a build-dependency of
utoipa-swagger-ui 9.0.2underpaladin-web'svendoredSwagger UI bundle. No published
utoipa-swagger-uirelease depends on the patchedzip >= 9(9.0.2 pins^3.0, 10.0.x pins
^8.6), so three register rows were added toSECURITY-EXCEPTIONS.md(owner, review date
2026-12-31, build-time-only scope, never-calls-extractcompensating control) and mirrored into
.cargo/audit.tomlanddeny.toml;make securityandscripts/check-advisory-register.sh
pass. Revisit whenutoipa-swagger-uimoves tozip >= 9. NoCargo.lockchange. -
Lockfile-only dependency refresh.
quinn-proto0.11.16 -> 0.11.19 (withquinn0.11.12 and
quinn-udp0.5.16), clearing RUSTSEC-2026-0338, -0339, -0340, -0341 and -0342, the five
vulnerability advisories that failed theSecurity AuditandLicense & Dependency Policy
CI jobs.quinnis reached only throughreqwest's optionalhttp3feature, which no
Paladin crate enables, so no first-party code path was exposed; the bump keepscargo audit
andcargo denygreen without adding an exception. The yankedspin0.9.8 andchacha20
0.10.0 releases were replaced with 0.9.9 and 0.10.2 at the same time. No.cargo/audit.toml,
deny.tomlorSECURITY-EXCEPTIONS.mdchange for these. Public API unchanged. -
A governed
unmaintained-class exception for RUSTSEC-2024-0449 (unsafe-libyaml, the parser
behindserde_yaml0.9 inpaladin-evaland the facade's optionalclifeature), which newly
failedcargo deny checkon every branch.serde_yamlis deprecated upstream with no upgrade
that drops the crate; the register row (SECURITY-EXCEPTIONS.md, mirrored indeny.toml)
records the local-file-only scope and names the migration to a safe-Rust parser
(serde-saphyr) as the revisit condition. YAML is never parsed from a network or request path. -
rustls0.23.45 -> 0.23.46 (lockfile only), clearing RUSTSEC-2026-0346, -0347, -0348 and -0349,
the four advisories published 2026-10-14 that re-failed theSecurity Auditand
License & Dependency Policyjobs the same day thequinn-protofix landed. No exception
added; no manifest change. Public API unchanged.
Breaking Changes
-
BREAKING (
paladin-ai-core): a stored v0.10BattalionConfig/ConclaveConfigdocument that still
carries a removed key is refused at load (D-23, Phase 46 plan 46-08; MIGRATION.md 10.1M-B-06).
BattalionConfigfails to deserialize when its document carriestimeout_seconds,retry_policyor
error_strategy, andConclaveConfigwhen it carriestimeout_secondsor its nestedbattalion_config
does. One error names every removed key present with its replacement (aegis.timeout.run_timeout,
aegis.retry,aegis.on_error;battalion_config.aegis.timeout.run_timeoutfor the Conclave's own
key), citesM-B-06and never echoes a value; a key present withnullis refused too. Phase 44 had
loaded such a document with the keys ignored, so a run v0.10 bounded loaded as unbounded. There is no
alias or compatibility mapping (ADR-0059 amended). Unrelated unknown keys still load, the public struct
shapes andSerializeoutput are unchanged, and the CLI's own conclave YAMLtimeout_secondskey is
unaffected. Delete or convert the keys in stored documents before upgrading. -
BREAKING (
paladin-llm): seven*Config::new(..)constructors take(api_key, model)
(SURFACE-02, D-05, D-06, D-07, D-09; Phase 50.1 plans 02, 04, 05 and 06).DeepSeekConfig,
GeminiConfig,QwenConfig,GrokConfigandKimiConfiggo fromnew(api_key, base_url, model)tonew(api_key, model);OpenAIConfig::new(api_key)becomesnew(api_key, model);
AnthropicConfig::new(api_key, base_url, model, max_tokens)becomesnew(api_key, model). There
is no alias:new(k, url, m)becomesnew(k, m).with_base_url(url), and an Anthropic caller that
passed a fourth argument adds.with_max_tokens(n). The first argument is always the API key,
never a URL. Each endpoint defaults to its constant in the type'sadaptermodule
(DEEPSEEK_DEFAULT_BASE_URL,GEMINI_DEFAULT_BASE_URL,QWEN_DEFAULT_BASE_URL,
GROK_DEFAULT_BASE_URL,KIMI_DEFAULT_BASE_URL,OPENAI_DEFAULT_BASE_URL,
ANTHROPIC_DEFAULT_BASE_URL); replace it withwith_base_url, and the timeout with
with_timeout_seconds. Both builders exist on all nine*Configtypes (see Added).
OllamaConfig::new(base_url, model)andOpenAiCompatibleConfig::new(api_key, base_url, model, capabilities)are the two endpoint-only exceptions and are unchanged (D-08).from_env()is
unchanged on every type. Two defaults worth knowing:OpenAIConfiggains the public field
model(set bynew, byfrom_env()toOPENAI_DEFAULT_MODEL="gpt-5.4-nano", and by the
layered path), a stored label that changes no adapter-wide limit (the window stays
context_windowor 128,000), andOpenAIConfig::with_organizationis new;
AnthropicConfig::newsetsmax_tokensto 4,096 for every model, the valuefrom_env()and the
layered path already used (owner decisioncompiled-default, 2026-10-20), so a model's larger
seeded maximum output is reachable only throughwith_max_tokens. Registered inMIGRATION.md
section 10.2 (one SURFACE-02 row per type) and section 10.8 (DR-19; DR-12 amended) and the seven
paladin-llm | <Type>Configallowlist entries (method_parameter_count_changed, measured with
cargo-semver-checks 0.50.0 against 0.10.1). -
BREAKING (
paladin-web):Principalgains the requiredtenant_id: TenantIdfield and is
marked#[non_exhaustive]— build it withPrincipal::new(id, role, tenant_id); a struct
literal outside the crate no longer compiles (TENANT-01; Phase 40 plan 40-01;MIGRATION.md
§10.2, allowlist entrypaladin-web | Principal). -
BREAKING (
paladin-web):AgentAuthConfiggainsbearer_tenant: Option<TenantId>— a
full struct literal must add it (..Default::default()sites are unchanged); a verified bearer
token does not authenticate while it isNone(TENANT-01; 40-01;MIGRATION.md§10.2). -
BREAKING (config):
http.auth.api_keys[].tenantis required on every API key, and
http.auth.bearer_token.tenantis required when bearer auth is enabled —paladin-server
refuses to boot otherwise, naming the key'snameand never its value; duplicate key names and
duplicate key values are also rejected at boot. This is the one deliberate exception to the
"every new config surface defaults to today's behaviour" rule (X-09): there is no implicit default
tenant (TENANT-01; 40-01, 40-03;MIGRATION.md§10.5, §10.8 step 3). -
BREAKING (
paladin-ports):SubmitRun.requested_by,ForkRun.requested_byand
RunSubmissionPort::cancel'srequested_byareOption<PrincipalRef>instead of
Option<(String, UserRole)>;Nonekeeps its internal-caller meaning (TENANT-01, TENANT-02;
40-01;MIGRATION.md§10.2, register-only entries). -
BREAKING (
paladin-ports, new-in-0.10 type):RunQuerygainsscope: RunReadScope—
..Default::default()sites are unchanged (All); a full struct literal must add it (PLAT-07;
40-02;MIGRATION.md§10.2). -
BREAKING (HTTP API): run reads are tenant-scoped — another tenant's run answers
404on
every/runs/{run_id}*route (GET,/stream,/webhook-deliveries,POST .../cancel) and is
absent fromGET /runs;/stream,/canceland/webhook-deliveriesalso answer501without
a run store. Cross-tenant operator reads need anadmin-role key (PLAT-07; 40-01, 40-05;
MIGRATION.md§10.6). -
BREAKING (
paladin-ai-core): the base wrapper isEntity<T>with payloaddata, and
persisted entity JSON is written with the new keys --Node<T>andbase::entity::nodeare
gone,ContentItem.nodeandPromptItem.nodeare.entity, the version family is
EntityVersion*, and the Citadel stores the realPaladinDataandPaladinStatus. v0.11 reads
the v0.10 keys but a v0.10 binary cannot read v0.11 output (ENTITY-01..05; ADR-0060;
MIGRATION.md10.1 M-B-12, 10.2 Phase 48 rows, 10.4). -
BREAKING (
paladin-llm): the nine adapter configs gainmax_tokens,max_retries,
context_windowandmodel_overridesand are marked#[non_exhaustive](OpenAIConfiggains
max_tokens,context_windowandmodel_overrides;AnthropicConfiggainsmax_retries,
context_windowandmodel_overrides) -- build one withneworfrom_envand set fields or
call thewith_*builders; a struct literal outside the crate no longer compiles. Owner
decisionoption-a(Phase 50 plan 50-08), so Phase 51'sreasoning_effortis free. Registered
with the semver measurement in plan 50-10. -
BREAKING (
paladin-llm):LlmProviderConfiggainsmax_tokens,context_windowandmodels
and is marked#[non_exhaustive]-- build one withLlmProviderConfig::new(api_key)and the
with_*builders (or deserialize it); a struct literal outside the crate no longer compiles.
Same owner decisionoption-aas the nine adapter configs (Phase 50 plan 50-09). Registered
with the semver measurement in plan 50-10. -
BREAKING (
paladin-ports):LlmResponsegainsreasoning: Option<String>, directly after
cost-- the vendor's reasoning text for the response,Nonewhen it returned none; a full
struct literal must addreasoning: None(D-11, PARITY-05; Phase 50 plan 50-11). The struct
stays constructible. Build responses withLlmResponse::new(..)andwith_reasoning(..),
which name no later field. Legacy JSON without the key deserializes withreasoning == None,
and a response without reasoning serializes byte-identically. Registered on the existing
paladin-ports | LlmResponseMIGRATION.md10.2 row and allowlist entry (the second break
aftercost), and in 10.8 DR-6. -
BREAKING (
paladin-ai):ModelFallbackConfiggainsstepsandAgentRuntimeDepsgains
llm_config-- two fields on constructible root types; a full struct literal must add
steps: Vec::new()orllm_config: None(or end in..Default::default(), which every
in-tree literal now does) (D-15, PARITY-06; Phase 50 plan 50-14). Both types still derive or
implementDefault. Registered on a newpaladin-ai | ModelFallbackConfigMIGRATION.md10.2
row and allowlist entry, and on the existingpaladin-ai | AgentRuntimeDepsrow and entry
(the second field aftercadence). -
BREAKING (
paladin-llm):FallbackChainErrorgains theEmptyStepModel { index }variant
-- the enum was already#[non_exhaustive], so amatchwith a wildcard arm is unaffected,
but its only variant was a unit variant, so a numeric cast of a variant
(FallbackChainError::EmptyChain as isize) stops compiling. Measured by cargo-semver-checks
asenum_discriminants_undefined_non_unit_variant(Phase 50 plan 50-14; D-15, PARITY-06);
registered on a newpaladin-llm | FallbackChainErrorrow and entry. -
BREAKING (
paladin-ai-core):PaladinResultgainsrun_metadata: BTreeMap<String, String>,
directly afterserved_by-- a full struct literal must addrun_metadata: BTreeMap::new()
(..Default::default()sites are unchanged;DefaultandPaladinResult::newgive an empty
map) (PARITY-02; Phase 50 plan 50-18, owner reading R-1). The struct stays constructible and
is still not#[non_exhaustive]. Measured by cargo-semver-checks as
constructible_struct_adds_fieldonPaladinResult.run_metadata; registered by extending the
existingpaladin-ai-core | PaladinResultMIGRATION.md10.2 row and allowlist entry. The
consumer takes it in its single move to v0.11.0. -
BREAKING (
paladin-ports):LlmError::EmptyCompletionandLlmError::Refusalare
#[non_exhaustive]struct variants that carry the bill of a billed-but-failed call (G-50-5a,
PARITY-05; Phase 50 plans 50-25 and 50-26).EmptyCompletion(String)becomes
EmptyCompletion { detail, billed }andRefusal { provider, model, detail }gainsbilled, an
Option<Box<BilledUsage>>. The newBilledUsage { model, usage, cost }holds the usage of a call
the vendor answered and charged for even though it returned no text or refused, so it is no
longer lost. Build the variants withLlmError::empty_completion(..)/LlmError::refusal(..)
(pluswith_billed_usage), match them withEmptyCompletion { .. }/Refusal { .. }, and read
the bill witherror.billed_usage(), which looks throughAllProvidersFailed.Displayand the
Permanentclassification are unchanged. Every adapter fills the bill on both paths, and it is
priced and settled (plans 50-27 to 50-30; see Fixed). Measured by cargo-semver-checks
asenum_tuple_variant_changed_kindandenum_variant_marked_non_exhaustiveon
EmptyCompletion, and registered on thepaladin-ports | LlmErrorMIGRATION.md10.2 row, its
allowlist entries and the 10.8 consumer row DR-17.
Known limitations
-
Phase 50 provider-parity limits (ADR-0061;
MIGRATION.md10.1 M-B-13 to M-B-22). (1) Seven of
the nine providers were probed on 2026-10-15; Ollama and OpenAI-compatible were skipped (no
daemon or endpoint), so they have no seeded models and run on adapter-wide values. Every model
the registry does not list does too, visibly unprofiled (paladin.unprofiled_model).
(2) Usage-cap (quota) mapping is unverified for Kimi, Qwen, Grok, Ollama and OpenAI-compatible:
no quota body was captured and the owner accepted each as unverified (owner reading R-2,
2026-10-16); each keeps the shared status mapping and an override is a singleerror_override
once a body is captured. (3) A caller that builds anLlmRequestdirectly gets omit,warn!and
metadata, not a refusal. (4) Usage and billing:TokenUsage.total_tokensstays
prompt + completion(D-02, kept by the owner). Every billed category a vendor reports is folded
into those two counts, and the vendor's own total is the check on them: a disagreement is
recorded underpaladin.usage_unreconciled(plans 50-21 to 50-24; Anthropic, which reports no
total, runs the containment checks only). Billed failures, hopped-past fallback bills and failed
or timed-out engine attempts are priced and settled (plans 50-26 to 50-30; see Fixed). The one
open item is Grok's vendor-computedcost_in_usd_ticksandnum_sources_used, which are not
parsed, so xAI's own cost is not compared with the Treasurer's price; the owner deferred it on
2026-10-17 and it stays open on theWINDOWS.mdledger. (5) The retry counters are not unified: Anthropic and Gemini count total
attempts, the other seven count retries after the first attempt. (6) The Grok and OpenAI
temperature upper bounds, and everytop_pand penalty span, are stated or contract-derived
rather than measured edges; the Switching providers page labels each. -
Phase 42 allowance halts are bounded, not exact (ALLOW-03, ALLOW-05;
WINDOWS.mdrows 65-67,
ADR-0057). Three limits are accepted and recorded. (1) The mid-run boundary check is a
check-only read with no reservation, so runs admitted in the same instant can all start and each
halts at its first boundary after the allowance is exhausted: overshoot is at most one
top-level superstep's spend per run (including any nested Battalion run that superstep
contains), and a per-superstep reservation hold is the deferred mitigation.
(2) A true streamedexecute/streamcall is one provider call and is not cut mid-flight: its
overshoot is that one call, and its terminaldonecarries an informationalhalt_reasononly
when the final usage crossed the derived figure, so it is not byte-identical to a pre-Phase-42
donein every case. (3) A graph Paladin node on a model with notreasurer.pricingrow settles
nothing, so no allowance can bound it; price every model a metered principal can reach. -
Tracing overhead still exceeds the ≤3% bar after the Phase 45 fixes (OBS-05). Re-measured
on the maintainer's machine on 2026-09-30 with thepaladin::tracetarget enabled:
+19.36% (log sink) and +16.19% (composite sink) against a 120.00µs untraced baseline on
the same all-Function-node synthetic superstep benchmark. These figures supersede the
[0.10.0]figures (+22.18% / +18.46%) for the next release; the two measurements are not
distinguishable given the run-to-run noise on a shared machine. Even with serialisation fully
skipped the sink path costs roughly +16% / +19%, so the remaining cost is not serialisation.
Tracing sinks stay opt-in andtrace.state_valuesstays default-off. The figure is
accepted-pending the maintainer's acceptance at Phase 45 UAT; the I/O-bound re-scope of the
bar remains deferred. Evidence:
.planning/phases/45-rustfs-swap-platform-observability-deviations/45-BENCH-EVIDENCE.md;
ledger row 61 (amends row 35).
Release Artifacts
Container image
docker pull ghcr.io/df3ndr/paladin-dev-env@sha256:8df8bd6585e10e1f63d7d4c458fa60cc46379e890f532a7d54c98ec1a3ae5c16
Pushed tag: ghcr.io/df3ndr/paladin-dev-env:0.11.0
Downloads and verification
SHA256SUMSpaladin-linux-amd64.tar.gzpaladin-linux-amd64.tar.gz.sha256paladin-linux-arm64.tar.gzpaladin-linux-arm64.tar.gz.sha256paladin-macos-amd64.tar.gzpaladin-macos-amd64.tar.gz.sha256paladin-macos-arm64.tar.gzpaladin-macos-arm64.tar.gz.sha256paladin-v0.11.0.cdx.json
Verify the downloaded archives against SHA256SUMS:
sha256sum -c SHA256SUMS
On macOS: shasum -a 256 -c SHA256SUMS
SBOM
paladin-v0.11.0.cdx.json is a CycloneDX SBOM for the root paladin-ai package only -- it does not cover the other crates in this workspace.
Image size
Measured image size: 86 MB -- within target (500 MB target, advisory only).