OrchestKit · #3789 · CC 2.1.251 · 2026-08-29

PreModelSwitch: the second door onto Fable, now guarded

The rule "no Fable spend without consent" had one door guarded: an agent spawn that pins a Fable id. The session switching itself via /model was wide open. CC 2.1.251 added a hook at exactly that seam. This page shows the payload as measured, the end-to-end run, and the defect the run caught.

1 · the payload, measured

What CC actually sends (not what the docs say, which is nothing yet)

Captured with a stdin-dumping hook on a real 2.1.251 -p session running /model claude-opus-5. PreModelSwitch and PostModelSwitch carry the same shape.

{
  "hook_event_name": "PreModelSwitch",
  "from_model": "claude-sonnet-5",
  "to_model": "claude-opus-5",
  "requested_model": "claude-opus-5",
  "source": "command",
  "context_tokens": 0,
  "prompt_cache_warm": false,
  "cache_ttl": "1h",
  "estimated_cache_write_usd": 0,
  "pricing": "catalog",
  "session_id": "…", "transcript_path": "…", "cwd": "…", "prompt_id": "…"
}

The binary's hook registry adds the output contract: "JSON permissionDecision allow/deny/ask as for PreToolUse; exit 2 blocks the switch", matcher field to_model. The same release put staleness on SessionStart resume: seconds_since_last_response, context_tokens, prompt_cache_likely_expired, estimated_cache_write_usd, session_title (measured; not the staleness_ms the binary's string table hints at, which belongs to the device bridge).

2 · the gate

One rule, two doors, one env var

SituationPreToolUse[Agent] (existing)PreModelSwitch (this PR)
Destination is Fable / Mythos, session is not on that tieraskask, quoting estimated_cache_write_usd when non-zero
Already on the premium tier (fable → fable[1m])n/aallow silently
ORK_FABLE_OK=1allowallow
Any non-premium destinationallowallow
--dangerously-skip-permissionsstill asks (spend is not a permission)still asks, same reasoning
What it meansThe consent rule is finally whole: spawn pins and session switches are both gated, by the same switch.
If we did nothing/model fable stays a silent 2x-Opus-output decision.
3 · the end-to-end run

Three real switches, before and after one fix

Same three claude -p commands, hooks wired to the built runner. The first pass was green on 58 unit tests and wrong in production.

RunCommandBefore the guard fixAfter
1/model claude-opus-5Set model to Opus 5Set model to Opus 5
2/model claude-fable-5, no consentSet model to Fable 5, then: "PreModelSwitch hook failed: hookSpecificOutput is missing required field""Model switch to Fable 5 was blocked by a PreModelSwitch hook: Switching this session from claude-sonnet-5 to claude-fable-5 puts every following turn on Claude Fable 5 pricing…"
3ORK_FABLE_OK=1 + /model claude-fable-5Set model to Fable 5Set model to Fable 5
telemetry~/.claude/analytics/model-switch.jsonl3 lines (incl. the switch that should have been blocked)+2 lines: exactly the two switches that happened
4 · the defect the run caught

A hook whose envelope the guard strips is not a hook that blocks

ork's runner passes every hook result through output-guard.mjs, whose allow-list of events that consume hookEventName lives in the generated cc-output-keys.generated.mjs (stamped at CC 2.1.226). PreModelSwitch was not in it, so the guard stripped the field, CC rejected the envelope, and by CC's own rule for a failed hook the switch proceeded. The gate failed open, and no unit test could see it because the guard runs only in the real runner.

What changedPreModelSwitch added to EVENTS_WITH_HOOK_EVENT_NAME and to the permissionDecision key set; spec updated to match; contract tests and derive --check green.
The lesson#3770 seen from the other side: for a blocking hook, "did CC honor the answer" is only observable end to end.
5 · resume staleness

Two smaller adoptions from the same release

HookBeforeNow
sync-session-dispatchersession-start-perf.jsonl records duration, source, messagesOn resume also records seconds_since_last_response, context_tokens, prompt_cache_likely_expired, estimated_cache_write_usd, warm_recent_resume
session-handoff-injectorInjected the previous session's handoff on every SessionStart it could claimSkips a resume that came back within 30 minutes onto a cache CC reports warm (conversation intact); cold or long-idle resumes still get it. Unknown fields (older CC) never read as "recent".