hank-model-authority
The model authority every product repository reads: which models exist (registry entries with their execution identity and per-family request facts), which model each capability uses (selection policy), and what each model costs (rate tables). The distribution depends on PyYAML and pydantic only, so Risk, Investment and Edgar take it without the gateway.
Mechanism — one artifact, admission, the host directory, lifecycle, skew — is
docs/reference/model-authority.md; the
data deploy is docs/runbooks/model-authority-deploy.md.
Layout
| Module | Owns |
|---|---|
authority/model-authority.yaml, authority/rates/*.json |
the packaged artifact: one model-authority/2.x document (registry models, selection.capabilities, one revision) and per-provider prices |
schema.py |
the version rule (SCHEMA_MAJOR/SCHEMA_MINOR), ModelAuthorityError and its message, protocol families and each family's compat: key set |
loader.py |
HANK_MODEL_AUTHORITY_DIR resolution, the strict load, the directory digest |
current.py |
the process's one import-time load: INITIAL_MODEL_REGISTRY, INITIAL_MODEL_SELECTION_POLICY, RATE_TABLES, HEALTH_FACT, health_fact() |
registry.py, selection.py |
registry entries and selection policy |
capabilities.py |
capability IDs and the process that executes each (a release, not data) |
binding.py, bind.py |
the single resolver and CapabilityBind |
rates.py, thinking.py |
rate tables; effort vocabulary |
__main__.py |
python -m model_authority check <dir>: the ordinary load of a candidate directory |
The package root imports types and the loader, never current, so
python -m model_authority check <dir> can check a candidate directory while the
host directory is broken.
Which directory a process loads
HANK_MODEL_AUTHORITY_DIR set: that directory, resolved once at start; missing or
unreadable fails the start. Unset: the packaged authority/ directory. Every
loading unit sets it — /etc/hank/model-authority in production,
~/.config/hank/model-authority on this Mac — so the packaged copy is what tests
and unwired tools load. Every process reports what it admitted through
health_fact(): {"revision", "schema", "sha256", "source", "loader", "admits"};
source is the resolved release path (…/model-authority.d/<revision>-<sha12>/…)
or packaged:<path>.
Schema
This loader implements model-authority/2.3 and admits 2.0 through 2.3 artifacts
(health_fact()["admits"] is model-authority/2.0-2.3): same major, minor at most
its own, every other field and literal strict; each refusal is one
ModelAuthorityError naming artifact, revision, schema, loader and admitted range.
Each entry's compat: key set is its protocol family's (schema.py): the profile
maps to anthropic.messages, openai.chat_completions, openai.responses or
google.generate_content (PROTOCOL_FAMILY_BY_PROFILE), and COMPAT_MODEL_BY_FAMILY
holds each family's frozen key set. 2.1 adds two anthropic.messages keys, each
derived when absent so 2.0 artifacts load unchanged, and each refused when it
contradicts the 2.0 key it refines:
| Key | Values | Absent | Must agree with |
|---|---|---|---|
thinking.off_value |
disabled, between_tools, null (cannot be turned off) |
disabled if can_disable, else null |
can_disable is true exactly when off_value is disabled |
tool_choice_forcing |
any, thinking_off_only, never |
thinking_off_only if forced_tool_choice_requires_thinking_off, else any |
that flag is false exactly when forcing is any |
The 2.0 keys stay required (a minor removes nothing), so an adapter that still
reads can_disable or forced_tool_choice_requires_thinking_off keeps working;
the gateway's Anthropic adapter reads both new keys. Sonnet 5.5 uses
can_disable: false, off_value: between_tools: the legacy bool still means
whether the literal thinking.type: disabled is legal.
Tier selection (2.2)
selection.tiers maps arbitrary non-empty role names to a default assignment
and an optional allowed_model_keys ceiling. Assignments are one of:
{models: [key, ...], effort: high}: ordered, non-empty execution candidates; this tier must declare a ceiling containing every candidate.{alias: deep, effort?: high}: use another tier's assignment, optionally replacing its effort. An absent ceiling inherits the target's ceiling over the base policy alias graph, before any override. Aliases must be acyclic and target an existing tier.{kind: inherit_parent}: copy the existing exact parent bind; no role ceiling.
Capability and channel defaults may be {kind: tier, tier: default, effort?: high}.
The static project_tier owner is shared by artifact admission and resolution:
first candidate qualified for the capability, in both the slot and referring
tier ceilings, and eligible for new selection. Effort is requested effort,
then slot-local effort, then resolved tier effort (outer alias effort wins).
The chosen candidate must support that exact effort; no clamp or next-candidate
retry occurs. Existing entitlement and credential checks follow selection, and
their failure never falls through to another candidate.
A reader that needs one capability's concrete no-override default (a startup
default entry, a dry-run label, a contract check) calls
project_capability_default(registry, policy, capability_id); it returns the
kind: model default as written and projects kind: tier through
project_tier. Never read .default.model_key directly: it is None for tiers.
resolve_capability_model adds keyword-only requested_role, role_map,
role_map_source and requested_role_source. A map's entries replace tier
defaults and may replace fallback chains; omitted roles use policy, and aliases resolve within that
effective map. Redirecting an alias cannot widen the referring role's fixed
base ceiling. Map values may be TierModelsDefault, TierAliasDefault,
TierInheritParentDefault, or their literal mappings.
Existing explicit, saved and authenticated concrete-model inputs win first.
Then requested role wins over channel/default tier selection. Selection source
is, in order: inherited assignment → parent_binding; explicit-user requested
role → explicit_user; an overridden assignment anywhere in its alias chain →
role_map_source (saved_preference or internal_policy, required for a supplied
map); other requested role → internal_policy; channel tier → channel_default;
otherwise the existing capability-default
source (capability_default for the driver, internal_policy for other slots).
Role/map provenance does not extend concrete-choice permission flags or tools.
CapabilityBind stays 1.0 with its existing source literals.
Inherited role assignments still enforce both the requested role's fixed ceiling
and the slot ceiling against the parent's model. Without a snapshot, concrete
selections without their own effort use applicable base-policy tier effort, never map effort.
Fallback chains and presets (2.3)
A role policy may declare fallback alongside default and allowed_model_keys:
deep:
default: {models: ["anthropic.claude-opus-5-5"], effort: high}
allowed_model_keys: ["anthropic.claude-opus-5-5", "codex.gpt-6-1-sol", "anthropic.claude-sonnet-5-5"]
fallback:
- {model: "codex.gpt-6-1-sol"}
- {model: "anthropic.claude-sonnet-5-5"}
Entries are ordered {model: key, effort?: exact} or {alias: role, effort?: exact}.
An absent or empty list means no chain; an alias role with no list inherits its
base target's chain, while its own list (including []) replaces inheritance.
Entry aliases capture the target role's base-policy assignment, filtered by
the target and containing role ceilings, never its fallback chain. Each alias is
one hop: project_fallback_chain(registry, policy, capability_id, requested_role)
chooses its first slot-qualified candidate inside the slot ceiling. The result
contains ordered entries (FallbackModel) and omitted entries with typed
reason codes; a slot unable to use an entry does not invalidate the artifact.
Admission checks entry keys, lifecycle, alias resolution, role ceilings and exact
effort. An omitted entry effort is checked against the containing role's default
effort at load but retained as None, meaning the bound effort at hop time.
An explicit entry effort is never clamped. Fallback admission refusals retain a
typed ModelAuthorityError.code (capability_model_unavailable,
capability_model_not_allowed, capability_role_unknown,
capability_role_unavailable, capability_effort_invalid,
capability_effort_unsupported or capability_selection_invalid).
selection.presets maps preset names to partial role maps. Entries take the
assignment shapes above plus optional fallback; ceilings remain base policy.
For example, economy: {default: {models: ["anthropic.claude-sonnet-5-5", "anthropic.claude-sonnet-5-5-sdk"], effort: high}}. The admitted
policy.presets and its maps are read-only. Unknown roles, alias cycles,
unavailable keys, unsupported efforts and widened ceilings fail at load.
Use role_map=policy.presets["economy"] with projection or snapshot capture.
A preset entry that omits fallback keeps that role's policy chain; a supplied
list replaces it. Fallback aliases remain base-policy assignments even when
the preset changes their target's primary assignment.
Frozen selection snapshots
capture_selection_snapshot(selection_policy, role_map=..., role_map_source=...)
returns an immutable SelectionSnapshot containing every capability's
CapabilityDefaults (default plus by_channel, each a CapabilityDefault) and
every capture-time tier's alias-resolved {models, effort} or
{kind: inherit_parent} assignment, plus its resolved fallback_chains.
overridden records roles whose effective alias chain used the winning map,
even when that map value equals the policy value. source is the winning map's
saved_preference or internal_policy literal exactly when overridden is
non-empty; otherwise it is null. Independent captures compare by value.
snapshot.to_json() returns this closed, versioned form:
{"version": 1, "policy_revision": "revision", "assignments": {"default": {"models": ["model-key"], "effort": "high"}}, "fallback_chains": {"default": [{"models": ["fallback-key"], "effort": null}]}, "capabilities": {"session.driver": {"default": {"kind": "tier", "tier": "default"}, "by_channel": {}}}, "overridden": ["default"], "source": "saved_preference"}
The example shows one slot; a snapshot holds every capability that existed at capture. Top-level
keys have the order shown; assignments, fallback chains, capabilities, channels and overridden
names are sorted, while candidate arrays and chain entries preserve order. SelectionSnapshot.from_json
round-trips the value; missing/unknown keys, wrong types, unresolved aliases,
unknown efforts and empty candidate arrays raise TierProjectionError with
capability_selection_invalid.
Older snapshot JSON without fallback_chains loads with empty chains for all
captured roles. Each frozen chain entry holds the resolved base candidates for
one hop and its optional exact effort. project_fallback_chain accepts the same
role_map or selection_snapshot inputs; frozen chains use current slot/role
ceilings and qualification, not a later policy's chain.
Pass selection_snapshot=snapshot to resolve_capability_model or project_tier
instead of role_map (supplying both raises ValueError). Slot defaults,
channel defaults, slot-local effort and tier assignments are frozen; a later slot
repoint cannot select a newly published tier or concrete model. The referring
tier's ceiling, slot allowance, permission flags, capability exposure, lifecycle,
entitlement and credentials remain current. A tier absent from the snapshot or
removed from current policy is capability_role_unknown; no currently eligible
candidate is capability_role_unavailable. A snapshot is validated on its own
contents, not against today's capability set, so one journaled before a release
that adds or retires a capability still loads and serves its captured slots; a
requested slot it did not capture is capability_policy_missing, and a frozen
model key the current registry no longer has is capability_model_unavailable.
The exact effort rules and source
precedence above still apply: only overridden roles use snapshot.source, while
the others retain today's selection-source literal. Concrete explicit, saved or
run-override selections without effort use the frozen applicable slot default's
effort when a snapshot is supplied. Omitting the snapshot (or passing None)
leaves resolution unchanged.
Artifacts declaring 2.0 or 2.1 cannot contain tiers or kind: tier; artifacts
older than 2.3 cannot contain fallback or selection.presets. The packaged
artifact intentionally stays 2.1 until consumer loaders upgrade; publishing new
minor data before those upgrades would prevent those consumers from starting.
Changing it
- A data change (a model, a default, a price, a compat value) edits
authority/and ships byscripts/deploy_model_authority.sh, not by a release (runbook). Declare the lowest schema minor that covers the fields the artifact uses. - A schema change is a release: a new optional field or literal is a minor
(
SCHEMA_MINOR), a removal or redefinition a major. Deploy loaders before data that uses a new minor: a loader refuses an artifact whose minor is newer than its own, naming both. - A capability or its executing process is a release (
capabilities.py).
Check a candidate directory with the ordinary load:
python -m model_authority check <dir> # prints the health fact, or exits 1 with the refusal
Tests: .venv/bin/python3 -m pytest packages/model-authority/tests -q
(make test-area AREA=model_authority).
Metadata
Release files for hank-model-authority 1.5.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| hank_model_authority-1.5.0.tar.gz | 78.0 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| hank_model_authority-1.5.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 131.8 kB