{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://github.com/mmerterden/multi-agent-pipeline/pipeline/schemas/route-config.schema.json",
  "title": "Model routing configuration",
  "description": "Policy-driven model selection, OFF by default. Lives under prefs.global.modelRouting. Two layers, and the difference is the whole safety argument. Layer 1 chooses a rung INSIDE the Anthropic ladder and writes to the existing PHASE_MODEL_OVERRIDE seam - no new network path, no new account. Layer 2 would send a call to a non-Anthropic provider, and no call site can do that yet: a subagent is dispatched by the host, bulk-read.sh shells out to `claude -p --model <rung>`, and research_ask runs in the MCP server and picks its own model. model-dispatch.sh therefore refuses a non-Anthropic rung everywhere and falls to the rule's next rung, because an allowed rung nothing can honour is a rule the user believes worked. `provider` in cost-table.json is the seam a future call site would use. `scope` has no `host-session` member and that absence is enforced here rather than written in prose: rewriting the host's base URL would route the user's ENTIRE session, including work that has nothing to do with this pipeline, through a third layer - breaking the subscription's auth model and silently changing which model answers. Shipping this disabled is the same reasoning as modelFallback.fableEnabled: a cost control that is on by default is not a control.",
  "type": "object",
  "additionalProperties": false,
  "required": ["enabled"],
  "properties": {
    "enabled": {
      "type": "boolean",
      "default": false,
      "description": "Master switch, ships false. While false not one line of dispatch behaviour changes: rules are read by route-status and by nothing else. route-off sets this to false and KEEPS the rules, so turning it back on does not re-ask for a configuration the user already gave."
    },
    "strategy": {
      "type": "string",
      "enum": ["manual", "task-fit", "cost-ceiling"],
      "default": "manual",
      "description": "manual: only the explicit rules[] apply, nothing is inferred. task-fit: a rule may match on taskKind and the router picks the cheapest rung that clears it. cost-ceiling: rungs downgrade as the run approaches budgetCeilingUsd."
    },
    "scope": {
      "type": "array",
      "default": ["subagent"],
      "uniqueItems": true,
      "description": "Where routing is allowed to act. Every member names a call site the pipeline itself owns. There is deliberately no `host-session` member - see the title description.",
      "items": {
        "type": "string",
        "enum": ["subagent", "bulk-read", "research"]
      }
    },
    "rules": {
      "type": "array",
      "default": [],
      "description": "Ordered; first match wins. An empty list with enabled:true is valid and means 'routing is armed but nothing matches yet' - which route-status reports as such rather than as an error.",
      "items": {
        "type": "object",
        "additionalProperties": false,
        "required": ["when", "prefer"],
        "properties": {
          "when": {
            "type": "object",
            "additionalProperties": false,
            "minProperties": 1,
            "properties": {
              "persona": { "type": "string" },
              "phase": { "type": "integer", "minimum": 0, "maximum": 5 },
              "taskKind": {
                "type": "string",
                "enum": ["bugfix", "feature", "refactor", "chore", "component"]
              }
            }
          },
          "prefer": {
            "type": "array",
            "minItems": 1,
            "description": "Rungs in descending preference. Rung NAMES are the contract; model ids are not - they live in cost-table.json and change without a config edit.",
            "items": { "type": "string" }
          }
        }
      }
    },
    "budgetCeilingUsd": {
      "type": ["number", "null"],
      "default": null,
      "minimum": 0,
      "description": "Only consulted by the cost-ceiling strategy. null means no ceiling, which is not the same as a ceiling of 0."
    },
    "recordDecisions": {
      "type": "boolean",
      "default": true,
      "description": "Write every routing decision to the cost ledger: which rule matched, which rung it chose, and why. On by default because a router whose choices are not recorded cannot be audited after the fact - and the question that always arrives later is 'why did this run cost that'."
    }
  }
}
