Skip to content

Declared-task model routing

praetorctl models route makes an offline selection from .config/models/routing.yaml. It filters by declared task and model capabilities, checks projected request capacity against supplied counters and declared limits, and minimizes configured token cost. It does not dispatch an agent or call a provider. Prices, capabilities and task assignments are operator declarations, not measured quality or current availability.

Current execution path

Models route filters candidates by target task, capabilities, capacity headroom, and lowest configured token cost.
Model routing filter chain
Text description
  • Filter chain, in code order: 1. hasRoutingTag (tier.TargetTasks match), 2. hasTaskCapabilities (model.Capabilities match), 3. quotaEligible (RPM/TPM & 429 cooldown), 4. betterTaskRoute (lowest cost & ID tie-break).
  • Boxes: models route (task, tokens & capabilities), routing.yaml (.config/models/routing.yaml), LimitTracker (in-process or --usage snapshot), Selected TaskRoute (model, tier, cost, headroom), Excluded / Skipped (ineligible or higher cost).
  • models route → 1. hasRoutingTag (TaskRequest).
  • routing.yaml → 1. hasRoutingTag (LoadRoutingConfigContext) → 2. hasTaskCapabilities (task eligible) → 3. quotaEligible (caps match).
  • LimitTracker → 3. quotaEligible (usage counters) → 4. betterTaskRoute (within quota) → Selected TaskRoute (best route).
  • 2. hasTaskCapabilities → Excluded / Skipped (missing capability).
  • 3. quotaEligible → Excluded / Skipped (limit or cooldown).
  • 4. betterTaskRoute → Excluded / Skipped (cost or tie-break).
  • Scenario 1, candidate selected: Candidate passes task, capability, and quota checks, winning on configured cost. LoadRoutingConfigContext loads .config/models/routing.yaml; hasRoutingTag finds candidate tiers. hasTaskCapabilities verifies all requested capabilities are declared on candidate models. quotaEligible checks projected RPM, TPM, and ensures no active 30s 429 cooldown. betterTaskRoute compares estimated token costs to find the lowest-cost candidate. gpt-oss-small wins with lowest configured cost ($0) and ID tie-break; TaskRoute is returned.
  • Scenario 2, capability missing: A candidate lacking requested capabilities is excluded by hasTaskCapabilities. The request requires specific capabilities: tools, json. hasTaskCapabilities checks each model in the tier against required capabilities. Candidate missing requested capabilities is excluded from further evaluation.
  • Scenario 3, limit or cooldown: A candidate exceeding projected capacity or in 429 cooldown is skipped. Candidate matches declared target task and requested capabilities. projectTaskCapacity projects +1 RPM and request TPM against exhaustion threshold. quotaEligible returns false; over-limit or cooling candidate is skipped.
  • (Shortened to 2500 characters; docs/figures/model-routing.ts holds the full content.)

The CLI calls LoadRoutingConfigContext, optionally LoadUsageSnapshot and TrackerFromSnapshot, then ModelCapacityArbiter.SelectForTask. This is a real consumer of the Go router. There is no MCP route tool or scheduler dispatch connection in this slice. The MCP standards_dogfood_repair_status tool consumes the same advisory selection through repair planning, so oversized requests block repair admission there too. The older SelectModel tier-cascade and SelectOrthogonalAuditor helpers still exist, but have no production consumer. They do not establish that a running agent harness enforces their policies.

Select a declared task

Run from the Praetor checkout:

go run ./cmd/standardsctl models route \
  --task=feature_implementation --input-tokens=1000 --output-tokens=500

--config selects another routing file. --capabilities accepts a comma-separated list of required labels. Every requested label must appear in that model's capabilities list; a missing list grants no capabilities. No model ID, family, parameter count or historical benchmark score implies eligibility. The existing catalog has task assignments but no per-model capability declarations, so a capability-constrained request requires explicit configuration first.

The task must occur in a tier's target_tasks. All models in all tiers explicitly declaring that task are considered. A cheaper model in an undeclared tier remains ineligible, including a tier named by the legacy fallback_tier field. No eligible candidate is an error, never a silent downgrade or automatic escalation. The same target_tasks label also selects the text register and the optional output budget that models route reports; the register never changes the tier (see the text register guide).

For eligible candidates the configured estimate is:

cost_per_m_in * input_tokens / 1,000,000
  + cost_per_m_out * output_tokens / 1,000,000

Both prices must be explicit finite nonnegative numbers. Explicit zero is allowed; missing or null rates cannot win as free models. Rates must use a common currency or unit; no currency conversion is implemented. Token estimates are optional. With neither given, the route is tier-only: estimated_cost is 0 and candidates rank by the sum of their two per-million rates (basis says so), pinned by TestTierOnlyRouteRanksByRatesWithoutEstimates. Each given estimate still drives cost and the quota checks. Equal ranks resolve by lexicographic model ID, then tier name, independently of map or list iteration order.

The JSON result includes the exact loaded configuration SHA256, selected model, task/capabilities, token estimates, configured cost and capacity observation status. It also includes register_manifest_sha256, binding the reported register row to the exact repository manifest bytes used for resolution. This work does not update the repository's existing model IDs, task presets or rates. An existing zero rate is a declaration, not proof that the endpoint exists or that running it has no resource cost.

Explicit capacity observations

Without --usage, the CLI uses an empty in-process tracker and reports capacity_source: "unobserved", capacity_observed: false, with no recorded headroom value. quota_limits_known says whether both configured quota dimensions are positive. When both are known, projected_headroom describes utilization after the request, using zero as a provisional starting counter when observations are absent. These fields do not turn an unobserved selection into a live quota claim.

To use actual observations collected separately:

go run ./cmd/standardsctl models route \
  --task=feature_implementation --input-tokens=1000 --output-tokens=500 \
  --usage=/path/to/observed-usage.json

A snapshot is a JSON object with exactly these fields:

Field Required data
version Integer 1
captured_at Nonzero RFC3339 timestamp of the observations
models Object keyed by configured model IDs
Each model's current_rpm Explicit nonnegative integer request counter
Each model's current_tpm Explicit nonnegative integer token counter

A model observation may also include nonnegative total_spend, nonnegative integer error_count, RFC3339 last_429_time, and RFC3339 observed_at. An omitted observation time inherits the snapshot's captured_at; importing a snapshot never refreshes its observation time. Those fields must use their exact names. Duplicate keys, field-name aliases, unknown fields/models, null observations and missing counters fail. Do not fill absent observations with invented zeroes. When --usage is supplied, unobserved models and models lacking a positive RPM or TPM limit cannot be selected. Programmatic callers obtain the same behavior with TaskRequest.RequireObservedCapacity.

governance.quota_reading_max_age sets the maximum quota observation age as a positive duration (for example, 10m, the default when omitted from YAML). Programmatic configurations must set Governance.QuotaReadingMaxAge explicitly. A reading exactly at the maximum age remains eligible; older readings, missing observation times and future timestamps are refused. A stale reading is refused even during provisional selection. CalculateHeadroom, SelectModel and orthogonal auditor selection require a reading; task routing requires one when RequireObservedCapacity is set. An absent reading can still support the provisional task selection described above when that flag is false.

governance:
  exhaustion_threshold_percent: 85
  quota_reading_max_age: 10m

exhaustion_threshold_percent must be between 0 and 85 inclusive, retaining at least 15 percent of every configured provider quota dimension. Configuration validation rejects 86 and higher. Both RPM and TPM must fit the threshold.

Admission adds one request and the sum of both token estimates before checking each known limit against exhaustion_threshold_percent. At 80 percent, 7 recorded requests against a 10 RPM limit admits one more; 8 does not. Similarly, 700 recorded tokens against a 1,000 TPM limit admits 60 input plus 40 output tokens, but rejects 60 plus 41. Integer additions are checked before mutation, and threshold comparisons use the exact configured numeric value without rounding large counters through floating point. Headroom floats are diagnostics only.

A configured zero RPM or TPM limit disables that dimension in provisional selection; it does not prove an unlimited provider quota. A recorded 429 triggers the existing 30-second cooldown. Fully exhausted or cooling models remain ineligible. Orthogonal selection uses the configured threshold and requires positive headroom.

SelectModel returns the selected endpoint descriptor with Skipped candidates using the shared RouteSkip type. An exhausted tier cascades to its configured fallback. Stale, missing, unreadable, cooling and over-threshold readings carry explicit reasons in selection results or errors; task routes expose the same exclusions in skipped.

These are historical counter checks with an age limit; rolling-window expiry is not inferred. Actual RecordUsage and known reservation completions timestamp their observations; reservations, unknown completions and 429 events preserve the preceding quota observation time. total_spend and error_count do not produce a latency, success-rate or error-rate score. Advisory selection uses one coherent tracker snapshot and does not reserve anything.

Atomic reservations within a process

The Go API adds arbiter.ReserveForTask(ctx, request) (*TaskReservation, error) and tracker.FinishReservation(reservation, actual) error. A successful reservation selects and charges +1 RPM, estimated input+output TPM, and configured cost under one tracker lock. It enforces a positive max_concurrent_same_model; zero means reservation concurrency has not been configured and fails. Active reservations also affect advisory selection and headroom reads. Share the same LimitTracker between dispatchers and keep their routing configuration immutable while in use.

The opaque handle's Route() returns a detached description. Every successful reservation must be finished, including failed or cancelled dispatches; defer the cleanup in the owning runner and finish only after execution has actually stopped. Admission cancellation and declined requests write no counters or handles. Finishing does not require a context, so a cancelled request can still release its slot. No background task, provider request or expiry timer is started by this API.

Pass &router.ReservationUsage{Tokens: actualTokens, Cost: actualCost} when actual usage is known, including an explicit zero. Completion replaces the estimated tokens and cost, keeps the one RPM charge, and releases the active slot exactly once. Pass nil when actual usage is unknown: the charge stays conservative and the slot is released. Do not also call RecordUsage for that request; that older API is for independent consumption. Invalid actual usage leaves the handle active for correction or a final nil completion. Duplicate, copied and foreign handles are rejected without changing accounting.

Counters created only by estimated charges do not become observations, including after nil completion. ObservedUsage returns charged counters plus a boolean indicating whether supplied or actual usage exists; that boolean does not certify every charge as measured. A lone 429 event records cooldown, not measured quota counters. Accounting overflow saturates the affected counter, blocks subsequent admission for that model, releases the finishing slot and returns an explicit error. Later completions cannot reclaim saturated accounting. Legacy RecordUsage cannot wrap counters or reopen a blocked model.

One tracker admits at most 1,024 active handles and cannot add new reservation model identities beyond 1,024 tracked models. Counters are cumulative until a caller deliberately builds a new tracker from independent observations. No automatic reset is safe while reservations remain active. Separate processes, trackers or CLI invocations do not share this state: this is not fleet persistence, provider availability checking, or scheduler integration. models route remains advisory.

Maintain the catalog with models sync

praetorctl models sync merges the built-in seed list into routing.yaml. With discovery on, which is the default, it also adds the models installed on local Ollama and vLLM endpoints, so a model you pull or serve becomes routable.

praetorctl models sync                         # seed list plus this machine's local models
praetorctl models sync --discover-local=false  # seed list only; no daemon needed
praetorctl models sync --prune                 # remove flagged retired seed entries

Discovery queries every endpoint in --local-endpoints (default http://localhost:11434,http://localhost:8000), whatever its port. Each endpoint is asked for Ollama's /api/tags first and, when that path answers 404, for the OpenAI-compatible /v1/models that vLLM serves (internal/router/discovery.go). An endpoint that does not answer is listed as Endpoint skipped and the sync goes on with the others, keeping that endpoint's catalogued entries. A --prune run refuses instead, because it refuses to prune from a partial local inventory (internal/router/sync.go:386). TestDiscoverLocalModelsIsolatesFailingEndpoints and TestSyncCatalogIsolatesFailedDiscovery pin this.

A new entry's tier comes from the parameter-count tag in its ID (7b, 1.5b, 235b, 8x7b): below 5 billion is nano, below 20 lightweight, up to 35 midweight, and larger heavy-frontier. Only an ID without a size tag falls back to name tags such as qwen3 or haiku (ClassifyTier, pinned by TestClassifyTierSizeBoundaries). Its family is the training lineage the name starts with, read after the last / of a hosted path, so hf.co/<org>/Qwythos-9B-Claude-... is not anthropic; open weights of a vendor with a hosted API in the catalog (gemma, gpt-oss, qwen) share that vendor's family, because the orthogonal auditor guards against correlated errors, which hosting does not change (DetectFamily, TestDetectFamilyMatchesVendorPrefixOnly).

Each model entry records who owns it in source:

source Written by What a sync does with it
seed the seed list in internal/router/sync.go rewrites it in place from the seed list; retired seed entries leave with --prune
local discovery against a local Ollama or vLLM endpoint keeps it; a rediscovered ID is never added twice
absent an operator editing the file keeps it

Without --prune, a sync never removes an entry. Entries the seed list does not own stay in their tier and order, and tiers the defaults do not define stay with their descriptions and task labels. An installed model that is also a seed entry keeps its seed entry instead of appearing twice. If a sync would still lose an entry, which happens when the seed list drops a model the catalog marks source: seed, it writes nothing and exits with an error that lists the IDs and names --prune.

--prune removes only flagged seed entries (source: seed) that are retired; hand-declared entries, alias entries and local models are preserved as operator data, and the command prints each removed ID.

A catalog that does not load, for example with a duplicate ID or an unknown source value, is refused rather than overwritten; repair or delete it first. The write is bound to the bytes the sync read: contextopt.ReplaceSnapshot checks them again before replacing the file, so an edit made while the sync runs fails it instead of being lost, apart from a writer that races that final check.

Governance and the default tiers' metadata follow the same ownership rule as model entries, with or without --prune: each governance key and each default tier's description, target_tasks, fallback_tier and lane that the file declares is kept, an explicit false, zero or empty list included, and only an undeclared key takes the built-in default (internal/router/sync_settings.go). To return a setting to its default, delete the key and sync. internal/router/sync.go:458 preserves unowned tiers and internal/router/sync.go:465 preserves hand-declared models across prune, so a declared fallback to an operator tier is no longer dropped or left dangling by prune (internal/router/sync_test.go:289). The behavior is pinned by internal/router/sync_test.go and cmd/standardsctl/models_sync_test.go.

Nightly catalog check

.github/workflows/sync-models.yml runs the offline sync every night and fails when the result differs from the tracked catalog, which means a seed-list change landed without a regenerated routing.yaml. The job has read-only repository access and never commits. main requires a pull request, signed commits and passing checks with no bypass actor, so a bot push cannot land there, and this repository does not let GITHUB_TOKEN open pull requests. To clear the failure, run praetorctl models sync and land the result in a pull request.

Gateway aliases

An OpenAI-compatible gateway can serve router aliases (classes such as light, coding, reasoning or auto) and refuse the concrete model IDs its key cannot use, so a pinned ID the catalog lists may fail there. A catalog entry can therefore name an alias instead:

gateway:
  address: https://gateway.example.com/v1   # yours; the repository ships none
  key_env: PRAETOR_GATEWAY_API_KEY          # variable holding the bearer key; never stored
tiers:
  lightweight:
    models:
      - {id: gateway-light, family: openai, provider: gateway, alias: light,
         cost_per_m_in: 0, cost_per_m_out: 0}

An alias entry needs a gateway section, and id stays the unique catalog key (internal/router/gateway.go). The provider field is optional metadata; the router sends the alias verbatim as the model name in gateway chat completions and expands {target} to the bare alias in lane commands. A model declaring provider without an alias is refused at config load.

The key_env setting must name an environment variable starting with PRAETOR_GATEWAY_ followed by at least one character of [A-Z0-9_]. A single constant validated at config load enforces this allow-list and the refusal names the rule (e.g. GITHUB_TOKEN is refused). Nothing in code, defaults or docs names a gateway: the adopter configures its address and aliases.

models sync makes one bounded call per alias entry (a one-token chat completion, 10 second deadline) and records alias_status: answers or unanswered with the gateway's refusal in alias_reason. It does not trust the gateway's model listing, which can list more than the key may use. A probe that cannot run, such as an unset key_env variable, changes nothing and is reported as Alias not probed, as is a transport failure or an HTTP 408, 429 or 5xx answer, which say nothing about the alias; any other refusal is recorded as unanswered. The probe is opt-in (--probe-aliases) because it sends the value of key_env as a bearer token to the declared address: the address must be https unless it is a loopback host, and a change to the gateway section in a pull request needs the same review as a change to a CI secret binding. The route then applies three rules:

  • An alias entry is a candidate only while its status is answers. An unanswered or never probed alias is skipped and listed in the result's skipped array with the reason (alias has not been probed; run models sync --probe-aliases).
  • Alias exclusion is per tier. A tier holding at least one alias entry excludes its pinned non-local models, however cheap and whatever the probes said, because the gateway refuses concrete IDs; only models from a local runtime (source: local) stay, since they never pass through the gateway. A tier with no alias entry keeps its pinned models. When none of the aliases in an alias-holding tier answers, that tier fails closed with the skipped reasons.
  • The harness is told the alias, never a model ID behind it.

TestRouteNeverFallsBackToPinnedModelWhenGatewayServesAliases, TestRouteExcludesPinnedModelsWhenNoAliasAnswers and TestProbeAliasesRecordsAnswerAndReason pin these.

Lanes and outcomes

A lane says how a pick is executed. One lanes table in routing.yaml, read by the same loader as the rest, maps a lane name to its harness and headless command, and a tier or model names its lane (lane:; a model's own lane wins):

lanes:
  gateway-coding:
    harness: coding-harness
    command: [coding-harness, run, --model, "{target}", --task, "{task}"]
tiers:
  lightweight:
    lane: gateway-coding

The command is an argument vector, never a shell line. {target} expands to the alias of an alias entry or the model ID, {task} to the label; each stays one argument whatever it contains, and any other {name} is refused at load. The harness may be a local or gateway model through a headless coding harness, a free-tier CLI or the frontier agent; Praetor ships no lane, because the command lines are the adopter's.

models route --task <label> returns lane with name, harness, target and the exact command. A pick with no lane declared returns lane_note instead of a guess. TestEveryDeclaredLabelRoutesToAnExecutableLane routes every declared label.

Subscription windows

A subscription lane (a CLI on a flat-rate plan) shares one rolling usage window with every session and subagent on its account. When the window runs out, all of them stop at once. The window governor (internal/router/window.go, #1142) refuses new work on such a lane before that happens, and tells admitted work how fast it may spend.

A lane becomes governed when it names an account, and the account names the snapshot record that holds its window:

lanes:
  claude:
    harness: claude-code
    command: [claude, -p, "{task}"]
    account: max-a
governance:
  window:
    warn_percent: 65              # default 65
    stop_percent: 80              # default 80; 0 < warn < stop <= 100
    max_reading_age_seconds: 600  # default 600
    stop_horizon_minutes: 30      # default 30
    per_agent_percent_per_hour: 0 # burn floor per active agent; 0 turns it off
    accounts:
      max-a: {window_record: claude-code/five_hour}

Readings come from a tribunus snapshot (tribunusctl sync --out): a record with access_path: subscription_cli and a usage_window (used_percent, resets_at, and, when the source measures them, burn_percent_per_hour and active_agents). The record's provenance gives the source and the age. praetorctl models route --windows <snapshot> decides each governed candidate:

Level When Admission
unknown no snapshot, no record, a reading older than the maximum age or dated in the future, no used_percent, or a reset already past refused
stop used at or above the stop line, or the projected burn reaches it within the horizon and before the reset refused
warn used at or above the warn line, or the projected burn reaches the stop line before the reset admitted, with advice to finish the unit and checkpoint
ok otherwise admitted

The projected burn is the larger of the measured burn and active agents times the per-agent floor. The floor covers the minutes after a reset, when many agents wake together and the measured burn still reads low. A refused candidate appears in skipped with a reason that names the window, and the route falls through to ungoverned lanes. With none left, the command fails with the existing no-eligible-model error. An admitted pick carries window, the pacing signal: the level, used_percent, resets_at, projected_burn_percent_per_hour and pace_percent_per_agent_hour, which is the burn each agent may spend so the fleet reaches the stop line no earlier than the reset.

The tests in internal/router/window_test.go replay the acceptance cases of #1142, the warn and stop boundaries, and the agent-scaling fixture from the issue thread. Two sources write window records today: codex-local (tribunus/internal/sources/codexlocal), which copies the provider's own used_percent, and claude-local (tribunus/internal/sources/claudelocal), which derives one from the Claude Code session logs (tribunusctl sync --sources=claude-local [--claude-projects-dir <dir>]). A lane on any other client stays unknown until a source writes its records.

claude-local reads only usage fields: per-response token counts, the model, the timestamp, and the quotaLimits of rejected requests. Message content is never decoded. It reports claude-code/five_hour, claude-code/seven_day, and every other rateLimitType a rejection names (a suffix such as seven_day_opus counts only that model family). For each window:

  • Usage is priced at first-party API rates (cost-equivalent) and counted once per response.
  • The ceiling is the smallest usage a fully logged window held at its first rejected request.
  • The reset is the last reported resetsAt, advanced in whole window steps.
  • used_percent is the current window's usage over the ceiling.
  • burn_percent_per_hour and active_agents cover the last 15 minutes.

Without a ceiling (no rejection in the logs and none stored), or with usage of a model the rate table lacks, the record has no used_percent and says why in absent, so the governor refuses as unknown. Files last written more than seven days ago are not read.

A ceiling outlives the logs it came from. sync keeps one store per account, claude-ceilings/<account>.json below --claude-state-dir. The default state dir is praetor/tribunus under the user's state folder: $XDG_STATE_HOME or ~/.local/state, %LOCALAPPDATA% on Windows. The store records each window's ceiling, the rejection that set it (time and log file), the reset it reported, and when it was recorded.

  • A rejection in the logs newer than the stored one replaces the stored entry.
  • A stored ceiling serves after a reset or a restart until its rejection is older than --claude-ceiling-max-age (default 720h). After that the window is unknown again.
  • The store is written whole to <file>.new and renamed into place.
  • A store that does not decode or validate fails the source; it is never repaired or overwritten.

tribunusctl claude-limits prints how often each rateLimitType and quotaLimits status value occurs in all session logs, and nothing else. On the office workstation on 2026-10-13 it found five_hour 148 times, all rejected, across 742 files; the weekly names (seven_day, seven_day_<family>) were not observed there. The tests in tribunus/internal/sources/claudelocal use synthetic logs only; tribunus/cmd/tribunusctl/sync_windows_test.go runs a produced snapshot through the governor.

praetorctl models outcome --task <label> --target <t> --result ok|fail|timeout [--lane <name>] [--duration-ms n] [--note text] [identity flags] appends one record to .workingdir/routing/outcomes.jsonl (--outcome-log changes it), a private JSON Lines log that is never rewritten. It is the measured routing data the efficiency ledger reads through router.ReadOutcomes, which fails on a record it cannot decode instead of averaging over the rest. The router does not dispatch: the caller that runs the command records how it ended.

Every outcome record requires a verified run identity:

  • --physical-model <id> (always required): the model that ran, for example claude-3-7-sonnet-20250219. The target is never copied into it, because a gateway alias such as cordana-coding need not be declared in the catalog. When the routing catalog (--config, default .config/models/routing.yaml) loads, a value naming any of its aliases, or the catalog ID of any alias entry, is refused. When no catalog is found the command says on stderr that the value was not checked. For a pinned target, pass the target or the model the provider resolved it to.
  • --harness <name> and --harness-version <version>: harness that ran the task.
  • --prompt-digest <sha256:...>: SHA-256 digest of prompt template or brief.
  • --context-digest <sha256:...>: SHA-256 digest of compiled context.
  • --context-bytes <n>: byte size of compiled context.
  • --tools <list>: comma-separated tool names available during the run, at most router.MaxIdentityTools (4096). The record keeps tool_set_digest (SHA-256 over the sorted names, one per line, so listing order does not matter) and tool_count. --record-tool-list keeps the full list as well; a record that then exceeds the 4096-byte line bound is refused with that reason.
  • --rounds <n> and --retries <n>: interaction rounds and prior retries.
  • --cost-estimate <dollars> and --actual-cost <dollars>: pre-dispatch cost estimate and post-run measured cost. An omitted flag leaves the field absent; --cost-estimate=0 records a measured zero.

An outcome record without mandatory identity fields is refused by ValidateOutcome. Two runs differing only in prompt template produce distinct identity keys (Key()). estimate_error (actual_cost - cost_estimate) is set only when both costs are present. After recording, the command prints it to stderr per lane (estimate-error [<lane>]: ...), or not measured when either cost is missing. --reconcile prints one line per lane across the log. Its sums cover measured runs only, that is, runs with a run identity, an estimate and an actual cost (Outcome.MeasuredCosts, router.CostTally); the efficiency ledger sums units by the same rule.

Catalog freshness

Every entry may carry preview: true and an as_of date (YYYY-MM-DD) for when its data was written or confirmed; a model ID containing preview counts as preview. models sync lists each preview entry and each entry whose as_of is older than governance.catalog_max_age_days (default 180) as Catalog stale. An answering alias probe sets as_of to the sync date and a discovered local model gets the sync date. An entry without as_of, and an entry owned by the seed list (source: seed), is not judged on age: the seed list ships inside the binary, so a fixed date would turn the audit red on that date with no change in the repository. The preview check applies to every entry, seed included, so the seed prices are only as current as the binary.

praetorctl audit runs the same check over .config/models/routing.yaml (auditModelCatalog) and fails on any finding; a repository without that file skips it, saying so, and a file that does not load fails. Under the operator-data rule (operator data is configured, never deleted), praetorctl models sync --prune removes only entries with source: seed that the freshness check flags (for example retired preview models); it never removes hand-declared or alias entries, and never touches the gateway or lanes sections. A plain models sync refuses to remove flagged seed entries without --prune. The audit remedy names each flagged hand or alias entry for the operator to refresh (as_of) or remove by hand; retired seed entries leave with models sync --prune --discover-local=false or by hand. The seed list no longer carries the three preview models it once did. Tests: TestAuditModelCatalogFailsStaleAndPreviewEntries, TestAuditModelCatalogWindowBoundary, TestSyncProbesAliasesAndReportsStalePreviewEntries, TestSyncPruneRemovesOnlyFlaggedSeedEntries.

Bounds and configuration migration

Configuration and snapshot files must be regular files no larger than 1 MiB. Routing accepts version 1, up to 16 tiers, 64 models per tier, 64 task/capability labels per list, 256 bytes per name and 1,024 snapshot model entries. Model IDs are globally unique. Each token estimate is bounded at 1,000,000,000; that is an input safety bound, not an asserted model context-window limit. Overflowing cost calculations fail. Reads and task selection accept caller cancellation.

Migration: the shared loader used by models list and models route now rejects missing/null prices, unknown fields, duplicate IDs/tags, unsupported versions, invalid bounds and nonregular input paths. Provide both numeric cost fields for each model, remove misspelled/unknown keys, and use a regular config file. Existing shipped routing data and models sync output already include both prices. This shared validation prevents listing one ambiguous file as free while routing treats it differently. Programmatically constructed task-routing descriptors must set CostRatesDeclared when both configured rates are intentional.

Migration: praetorctl audit now fails on preview or stale catalog entries (auditModelCatalog). Retired seed previews must be removed by hand or with praetorctl models sync --prune --discover-local=false. A non-loopback HTTP gateway address is refused; use HTTPS for external gateways. Gateway alias probing is opt-in via --probe-aliases. Gateway key_env must begin with the PRAETOR_GATEWAY_ prefix followed by at least one character of [A-Z0-9_]. Alias exclusion is scoped per tier rather than catalog-wide, so tiers holding alias entries exclude pinned non-local models and fail closed if none answer, while tiers without alias entries retain pinned models. models sync --prune removes only flagged seed entries (source: seed) that are retired; hand-declared entries, alias entries, local models, and gateway/lanes configurations are preserved as operator data. Flagged hand or alias entries must be refreshed (as_of) or removed by hand as reported by praetorctl audit.

Migration: model entries accept an optional source field whose only values are seed and local; any other value is rejected. models sync now merges instead of rewriting, and removes entries only with --prune. A script that relied on a plain models sync to discard entries must pass --prune. A catalog committed with a duplicate ID, which earlier sync runs could produce by listing an installed seed model twice, no longer loads; delete the second copy.

Migration: requests that cross a configured RPM/TPM threshold now fail before selection, including oversized requests against an empty tracker. Supply realistic token estimates and explicit applicable limits. --usage and RequireObservedCapacity additionally require positive limits for both dimensions. Consumers may read the additive quota_limits_known and projected_headroom fields; existing recorded_headroom remains the pre-request counter diagnostic. Dispatch consumers must opt into the reservation API, configure positive concurrency, and finish every successful handle; calling advisory selection alone does not enforce concurrency.

Migration: outcome records now require mandatory run-identity fields (physical model, harness, version, prompt digest, context digest), and every models outcome call needs --physical-model, whatever the target. Pre-existing outcomes.jsonl logs written before run identity stay readable: ReadOutcomes skips the identity checks only for a record that carries none of the run-identity fields (identity, identity_key, resolved_model, actual_cost, estimate_error, branch). Such a record counts as a run in lane reconciliation but never as a measured cost. Any record with one of those fields is validated in full.

Remaining dispatch and feedback work

Automatic agent dispatch, shared fleet reservations, feeding the outcome log back into selection, quality calibration, retry/escalation policy and cross-model review orchestration remain unimplemented integrations. The declared orthogonal_audit_required setting does not establish scheduler enforcement. The concurrency setting is enforced only by callers using the shared tracker reservation API. The standalone .config/agent/hooks/pre_agent_dispatch.py script has no verified hook registration here; this command does not invoke it.

models list displays routing configuration. The separate models sync path merges a built-in catalog and can query configured local Ollama endpoints (see Maintain the catalog with models sync). It is not called by models route. There is no implemented upstream gateway pricing synchronization in this path, and the catalog it merges into is the routing file itself (.config/models/routing.yaml, the --config default), not a separate catalog file. Future work must connect execution and real observation sources explicitly before claiming live fanout or evidence-driven escalation.