{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://airlock.lunareng.com/schema/role.schema.json",
  "title": "roles/<role>/role.yaml",
  "description": "An agent role: a standing job with its own identity, level, tools and judge. Repos say what may change (AUTONOMY.yaml); roles say who does the changing and with what. Airlock grants each run of it from this file at the run's commit.",
  "type": "object",
  "additionalProperties": false,
  "required": [
    "schema",
    "role",
    "level",
    "brief",
    "writes",
    "tools",
    "budget"
  ],
  "properties": {
    "schema": {
      "type": "integer",
      "const": 1
    },
    "role": {
      "type": "string",
      "pattern": "^[a-z][a-z0-9-]*$"
    },
    "level": {
      "$ref": "#/$defs/level",
      "description": "The role's own level. A change the role makes is still bounded by the target repo's AUTONOMY.yaml: the effective level is the stricter of the two."
    },
    "brief": {
      "type": "string",
      "description": "Path to the role's standing brief (what the scheduled task reads at the start of every run)."
    },
    "goals": {
      "type": "string",
      "description": "Path to the role's goals file. Required for L4."
    },
    "judge": {
      "type": "object",
      "description": "Required for L4. A number the role cannot write to, computed by CI.",
      "additionalProperties": false,
      "required": [
        "metric",
        "source",
        "pass"
      ],
      "properties": {
        "metric": {
          "type": "string"
        },
        "source": {
          "type": "string"
        },
        "pass": {
          "type": "string"
        },
        "check": {
          "description": "Required for L4 (scripts/validate.py). One check, or a list when the judge has several sources; it ran only if every one of them ran.",
          "oneOf": [
            {
              "$ref": "#/$defs/judge_check"
            },
            {
              "type": "array",
              "minItems": 1,
              "items": {
                "$ref": "#/$defs/judge_check"
              }
            }
          ]
        }
      }
    },
    "identity": {
      "type": "object",
      "description": "The login every PR, issue, label and board post traces to: the Airlock App's bot login for a role on Airlock.",
      "additionalProperties": false,
      "properties": {
        "github_login": {
          "type": "string"
        },
        "app_id": {
          "type": "integer",
          "minimum": 1,
          "description": "The id of a GitHub App of the role's own (not a secret). Absent for a role on Airlock, whose runs act as the organization's Airlock App."
        },
        "board_name": {
          "type": "string",
          "description": "How the agent board knows the role, e.g. mcp:ops-agent."
        },
        "key_store": {
          "type": "string",
          "pattern": "^(github|airlock|ssm:/[A-Za-z0-9/_.-]+|local:~/[A-Za-z0-9/_.-]+)$",
          "default": "github",
          "description": "`airlock`: the role has no key of its own; Airlock mints its GitHub token for each run from this file, as the organization's Airlock App. Any other value names where a key of the role's own App is kept (`github`, `ssm:<prefix>`, `local:~/<path>`), and Airlock grants that role nothing."
        }
      }
    },
    "writes": {
      "type": "object",
      "description": "What the role may change, and where. Anything not listed is read-only for it. Generated allowlists and the role's brief both derive from this.",
      "additionalProperties": false,
      "properties": {
        "repos": {
          "type": "array",
          "items": {
            "type": "string",
            "pattern": "^[A-Za-z0-9-]+/[a-z0-9.-]+$"
          },
          "description": "Repos the role may write to at all (issues, labels, PRs). Everything else is read-only."
        },
        "issues": {
          "type": "array",
          "items": {
            "type": "string",
            "enum": [
              "create",
              "label",
              "edit_body",
              "comment",
              "close",
              "reopen",
              "assign"
            ]
          }
        },
        "pull_requests": {
          "type": "array",
          "items": {
            "type": "string",
            "enum": [
              "open",
              "merge",
              "review",
              "label",
              "comment"
            ]
          }
        },
        "board": {
          "type": "array",
          "items": {
            "type": "string"
          },
          "description": "Board channels the role may post to."
        },
        "workflows": {
          "type": "boolean",
          "description": "The role's App may create and change .github/workflows/ files in its repos (GitHub refuses them without Workflows: write). Granted by the human owner on the organization's Airlock App; a repo can score .github/workflows/** L2 in its policy."
        },
        "reads": {
          "type": "array",
          "items": {
            "type": "string",
            "enum": [
              "actions"
            ]
          },
          "description": "Extra read scopes beyond issues, PRs and contents: actions (workflow runs)."
        },
        "dispatch": {
          "type": "boolean",
          "description": "The role's App may start this repo's workflows (POST .../actions/workflows/{id}/dispatches), which GitHub grants only with Actions: write. The router dispatches roles for tool-requests and reviews; the clock dispatches every timed workflow (generated/schedules.json)."
        }
      }
    },
    "tools": {
      "type": "object",
      "description": "MCP tool allowlist by connector. Globs allowed (a prefix ending in *). Board tools (board_*) are required for every role: the board is how roles find each other's work. Through Airlock's gateway a tool outside the list is refused: MCP's tool-name characters (A-Za-z0-9_.-), then an optional *.",
      "additionalProperties": {
        "type": "array",
        "items": {
          "type": "string"
        }
      }
    },
    "resources": {
      "type": "object",
      "description": "MCP resources the role may read, by gateway connector: a URI, a prefix ending in /*, <scheme>:* for every URI of a scheme, or * for all. Printable ASCII only. Anything else is refused.",
      "propertyNames": {
        "pattern": "^[a-z0-9][a-z0-9-]{0,39}$"
      },
      "additionalProperties": {
        "type": "array",
        "items": {
          "type": "string",
          "minLength": 1
        }
      }
    },
    "deny": {
      "type": "object",
      "description": "What the role may never call or read, by gateway connector, over any allow: tool patterns, and resource patterns as in resources. A deny matches a URI in any case, without its query, fragment or trailing /.",
      "propertyNames": {
        "pattern": "^[a-z0-9][a-z0-9-]{0,39}$"
      },
      "additionalProperties": {
        "type": "array",
        "items": {
          "type": "string",
          "minLength": 1
        }
      }
    },
    "args": {
      "type": "object",
      "description": "Rules for an allowed tool's arguments, by gateway connector, then tool, then parameter. A parameter with a rule must be present.",
      "propertyNames": {
        "pattern": "^[a-z0-9][a-z0-9-]{0,39}$"
      },
      "additionalProperties": {
        "type": "object",
        "additionalProperties": {
          "type": "object",
          "additionalProperties": {
            "type": "object",
            "additionalProperties": false,
            "properties": {
              "enum": {
                "type": "array",
                "minItems": 1,
                "items": {
                  "type": [
                    "string",
                    "number",
                    "boolean"
                  ]
                }
              },
              "const": {
                "type": [
                  "string",
                  "number",
                  "boolean"
                ]
              },
              "min": {
                "type": "number"
              },
              "max": {
                "type": "number"
              },
              "maxLength": {
                "type": "integer",
                "minimum": 0
              },
              "pattern": {
                "type": "string",
                "description": "Anchored: the whole value must match. Short, with no backreference, lookaround or quantified group."
              }
            }
          },
          "propertyNames": {
            "not": {
              "const": "__proto__"
            }
          }
        },
        "propertyNames": {
          "pattern": "^[A-Za-z0-9_.-]{1,128}$",
          "not": {
            "const": "__proto__"
          }
        }
      }
    },
    "budget": {
      "type": "object",
      "additionalProperties": false,
      "properties": {
        "runs_per_week": {
          "type": "integer",
          "minimum": 1
        },
        "minutes_per_run": {
          "type": "integer",
          "minimum": 1
        },
        "github_writes_per_run": {
          "type": "integer",
          "minimum": 0
        },
        "spend_usd": {
          "type": "number",
          "minimum": 0,
          "default": 0
        }
      }
    },
    "digest": {
      "type": "string",
      "enum": [
        "friday",
        "monthly",
        "none"
      ]
    },
    "floors": {
      "type": "array",
      "items": {
        "type": "string"
      },
      "description": "Things this role never does, on top of the charter's. Listed so the brief and the allowlist generator both carry them."
    },
    "kind": {
      "type": "string",
      "enum": [
        "agent",
        "workflow"
      ],
      "default": "agent",
      "description": "agent: a model runs the brief in a scheduled task. workflow: a GitHub Actions job runs code; no model, no board tools."
    },
    "run": {
      "type": "object",
      "additionalProperties": false,
      "description": "How an agent role runs as an Actions job (its workflow is .github/workflows/role-<role>.yml in the policy repo). Workflow roles have their own hand-written workflow and omit this.",
      "required": [
        "model"
      ],
      "properties": {
        "schedule": {
          "type": "string",
          "minLength": 1,
          "description": "Optional 5-field cron, UTC (Actions has no CRON_TZ). Omit for a role with no timer of its own; it can still be dispatched."
        },
        "model": {
          "type": "string",
          "description": "Model id for the selected provider. The workflow must select a model compatible with that provider."
        },
        "provider": {
          "type": "string",
          "enum": ["claude", "codex"],
          "default": "claude",
          "description": "Agent CLI the workflow should launch: Claude Code or OpenAI Codex. Omitted means the legacy Claude default. See the runner documentation for launcher availability; Mint's grant is independent of this setting."
        },
        "max_turns": {
          "type": "integer",
          "minimum": 1,
          "default": 200
        },
        "clone": {
          "type": "array",
          "items": {
            "type": "string",
            "pattern": "^[A-Za-z0-9-]+/[a-z0-9.-]+$"
          },
          "description": "Repos checked out into repos/<name> for the run, with the role's App token. Defaults to writes.repos."
        },
        "bash": {
          "type": "array",
          "items": {
            "type": "string"
          },
          "description": "Bash command prefixes the run may use, e.g. 'gh api', 'git log'. Everything else is denied."
        },
        "tools": {
          "type": "array",
          "items": {
            "type": "string",
            "enum": [
              "WebFetch",
              "WebSearch",
              "Agent"
            ]
          },
          "uniqueItems": true,
          "description": "Built-in Claude Code tools beyond file and bash access: WebFetch and WebSearch read the public web (Academy's content routine checks AWS facts against AWS documentation); Agent spawns a fresh subagent (the outside judge and student test that a repo's own rules require before a merge). Only these three can be granted."
        },
        "mcp": {
          "type": "object",
          "description": "Connector name -> how the run reaches that MCP server. Through Airlock's MCP gateway ({via: airlock}): the server is declared in config.yaml's mcp_connectors, Airlock holds its address and credential, and the role's tools, resources, deny and args for it are what the gateway holds each call to. Or directly ({url, header}): the bearer comes from the Actions secret ROLE_<ROLE>_<CONNECTOR>_TOKEN, or a session Airlock signs; `header` is how the server takes it.",
          "additionalProperties": {
            "oneOf": [
              {
                "type": "object",
                "additionalProperties": false,
                "required": [
                  "via"
                ],
                "properties": {
                  "via": {
                    "const": "airlock",
                    "description": "Through Airlock's MCP gateway."
                  }
                }
              },
              {
                "type": "object",
                "additionalProperties": false,
                "required": [
                  "url"
                ],
                "properties": {
                  "url": {
                    "type": "string"
                  },
                  "header": {
                    "type": "string",
                    "default": "Authorization",
                    "description": "Header name; Authorization sends 'Bearer <token>', any other sends the raw token."
                  }
                }
              }
            ]
          },
          "propertyNames": {
            "pattern": "^[a-z0-9][a-z0-9-]{0,39}$"
          }
        }
      }
    },
    "scopes": {
      "type": "object",
      "description": "Scopes the role's MCP sessions hold, by scope family, as opposed to `tools`, which is what the model sees. Airlock signs each session with these scopes for the servers in `run.mcp`. Families: capcom, board, houston, ops (read, write where listed) and agents (sync). Sending, consent and suppression are floors and cannot be listed.",
      "additionalProperties": false,
      "properties": {
        "capcom": {
          "type": "array",
          "items": {
            "type": "string",
            "enum": [
              "read",
              "write"
            ]
          }
        },
        "board": {
          "type": "array",
          "items": {
            "type": "string",
            "enum": [
              "read",
              "write"
            ]
          }
        },
        "houston": {
          "type": "array",
          "items": {
            "type": "string",
            "enum": [
              "read"
            ]
          }
        },
        "agents": {
          "type": "array",
          "items": {
            "type": "string",
            "enum": [
              "sync"
            ]
          }
        },
        "ops": {
          "type": "array",
          "items": {
            "type": "string",
            "enum": [
              "read"
            ]
          }
        }
      }
    }
  },
  "allOf": [
    {
      "if": {
        "properties": {
          "level": {
            "const": "L4"
          }
        }
      },
      "then": {
        "required": [
          "judge",
          "goals"
        ]
      }
    }
  ],
  "$defs": {
    "level": {
      "type": "string",
      "enum": [
        "L1",
        "L2",
        "L3",
        "L4"
      ]
    },
    "judge_check": {
      "type": "object",
      "description": "Where the ratchet looks for the judge's last run. A CI judge names its workflow file; a report judge names the label of the issue its report is commented on. The ratchet only reads when it last ran, never its number.",
      "additionalProperties": false,
      "required": [
        "repo",
        "max_age_days"
      ],
      "oneOf": [
        {
          "required": [
            "workflow",
            "branch"
          ],
          "not": {
            "required": [
              "issue_label"
            ]
          }
        },
        {
          "required": [
            "issue_label"
          ],
          "not": {
            "anyOf": [
              {
                "required": [
                  "workflow"
                ]
              },
              {
                "required": [
                  "branch"
                ]
              }
            ]
          }
        }
      ],
      "properties": {
        "repo": {
          "type": "string",
          "pattern": "^[A-Za-z0-9-]+/[A-Za-z0-9._-]+$"
        },
        "workflow": {
          "type": "string",
          "pattern": "^[A-Za-z0-9._-]+\\.ya?ml$",
          "description": "A CI judge: the workflow file in the repo's .github/workflows. It ran if a run on `branch` completed (success or failure; skipped and cancelled don't count) within max_age_days. A file that doesn't exist yet has never run."
        },
        "branch": {
          "type": "string",
          "enum": [
            "main"
          ]
        },
        "issue_label": {
          "type": "string",
          "description": "A report judge: the label of the open issue the report is commented on (the sims' daily `sim-coverage` comment). It ran if the account that opened that issue commented within max_age_days."
        },
        "max_age_days": {
          "type": "integer",
          "minimum": 1,
          "maximum": 7,
          "description": "Older than this, the judge has not run: one `judge-missing` catch. At 14 days the ratchet's tightening PR drops the repo or role from L4 to L3 (decided 2026-10-25)."
        }
      }
    }
  }
}
