{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://github.com/Sean-Koval/hangar/blob/main/docs/api/manifest.schema.json",
  "title": "hangar.manifest/v1: the effective harness manifest",
  "description": "The exact configuration an agent turn ran with (or, for an environment, would run with). Captured at turn start and stored with the turn. Additions within v1 are new optional properties; anything removed or renamed is a new schema version. Credentials never appear: MCP header and env entries are names only, and every string has passed through hangar's secret redaction. See docs/specs/0014-sister-tool-interfaces.md.",
  "type": "object",
  "required": ["schema", "digest", "config_digest", "captured_at", "subject", "hangar", "harness", "model",
               "instructions", "skills", "mcp_servers", "hooks", "permissions", "egress", "environment", "resources",
               "policy", "warnings"],
  "properties": {
    "schema": {"const": "hangar.manifest/v1"},
    "digest": {"$ref": "#/$defs/digest", "description": "sha256 over the canonical JSON of everything except the four digests, captured_at and subject."},
    "config_digest": {"$ref": "#/$defs/digest", "description": "sha256 over the harness configuration and its environment together (harness, model, instructions.global, skills, mcp_servers, permissions, egress, environment.name/image/spec_digest, resources): equal for two trials of one experiment variant, different between variants. Kept for compatibility; harness_digest and environment_digest are its two halves."},
    "harness_digest": {"$ref": "#/$defs/digest", "description": "Optional (absent from manifests stored before it). sha256 over the prefix and binding layer, what a harness author controls: harness.name, harness.locked, model.id, instructions.global.declared.digest, skills.declared[*].{name,digest}, plugins, mcp_servers[*].{name,transport,url,headers,env,command,args}, hooks[*].{event,source,digest}, permissions.approvals. Canonical form in spec 0014 §2."},
    "environment_digest": {"$ref": "#/$defs/digest", "description": "Optional (absent from manifests stored before it). sha256 over what the sandbox is: environment.name, environment.image, environment.spec_digest, environment.build.id, egress.effective, resources, permissions.uid_split, permissions.isolation. Canonical form in spec 0014 §2."},
    "captured_at": {"type": "number", "description": "Unix time, seconds."},
    "subject": {
      "type": "object",
      "required": ["kind"],
      "properties": {
        "kind": {"enum": ["turn", "session", "environment"], "description": "turn: captured as the turn started; session: what the session would run with now (no turn captured yet); environment: what a new sandbox of the environment would get."},
        "session": {"type": ["string", "null"]},
        "session_id": {"type": ["string", "null"]},
        "turn": {"type": ["integer", "null"], "minimum": 1},
        "run_id": {"type": ["string", "null"]},
        "job": {"type": ["string", "null"]},
        "person": {"type": ["string", "null"]},
        "sandbox": {"type": ["string", "null"]}
      }
    },
    "hangar": {"type": "object", "required": ["version"], "properties": {"version": {"type": "string"}}},
    "harness": {
      "type": "object",
      "required": ["name", "version", "locked", "pack", "mode"],
      "properties": {
        "name": {"type": ["string", "null"], "description": "claude-code, codex, pi, antigravity, or another registered harness."},
        "version": {"type": ["string", "null"], "description": "The CLI version the sandbox's pack reported, when known."},
        "locked": {"type": ["string", "null"], "description": "The version harnesses.lock.yaml pins (the session's, pinned at its creation)."},
        "pack": {"type": ["string", "null"], "description": "The pack image mounted (a digest reference where the registry gives one)."},
        "mode": {"enum": ["pack", "bake", "image"]},
        "live": {"type": ["boolean", "null"], "description": "The turn ran under hangar's driver (steer, interrupt, approvals)."},
        "lock": {"type": "object", "required": ["name", "revision", "digest"], "properties": {"name": {"type": "string"}, "revision": {"type": "integer"}, "digest": {"$ref": "#/$defs/digest"}}, "description": "Optional (#134), present only then: the harness lock the session was thawed from. harness_digest equals its digest unless the turn drifted from it (docs/harness-lock.md)."}
      }
    },
    "model": {
      "type": "object",
      "required": ["id", "policy", "source", "provider"],
      "properties": {
        "id": {"type": ["string", "null"], "description": "The model id the harness was given (null: the harness's own default). A policy is always resolved to an id; an id given as it is can still be a floating alias (see concrete)."},
        "policy": {"type": ["string", "null"], "description": "The policy it was resolved from (frontier, balanced), or null when pinned."},
        "source": {"type": ["string", "null"]},
        "provider": {"type": ["string", "null"], "description": "gen_ai.provider.name"},
        "cache_ttl": {"type": ["string", "null"]},
        "concrete": {"type": "boolean", "description": "Whether id is a concrete, dated model id the provider can't move between two runs: in the provider's model list or of a known versioned shape, and not a floating alias (*-latest, OpenRouter's ~vendor/..., a bare family name such as opus or gpt-5). False with no id. Runs with concrete false are not pinned and evaluation leaves them out; experiments refuse to start on one. Optional (absent from manifests stored before it). Added within v1 (#167)."}
      }
    },
    "instructions": {
      "type": "object",
      "required": ["global", "repo"],
      "properties": {
        "global": {
          "type": "object",
          "required": ["path", "declared", "effective"],
          "properties": {
            "path": {"type": ["string", "null"], "description": "The harness's global instructions file, relative to $HOME."},
            "declared": {"oneOf": [{"type": "null"}, {"$ref": "#/$defs/text"}], "description": "The environment's agent_config.instructions."},
            "effective": {"oneOf": [{"type": "null"}, {"$ref": "#/$defs/file"}], "description": "The file in the sandbox as the turn started."}
          }
        },
        "repo": {
          "type": "object",
          "required": ["files"],
          "properties": {
            "repo": {"type": ["string", "null"]},
            "commit": {"type": ["string", "null"]},
            "files": {"type": "array", "items": {"allOf": [{"$ref": "#/$defs/file"}, {"required": ["path"]}]}, "description": "AGENTS.md, CLAUDE.md, .claude/CLAUDE.md and GEMINI.md in the checkout, by reference (path at commit) and digest."}
          }
        },
        "prompt": {"$ref": "#/$defs/text", "description": "The turn's prompt as hangar wrote it."}
      }
    },
    "skills": {
      "type": "object",
      "required": ["declared", "effective"],
      "properties": {
        "dir": {"type": ["string", "null"]},
        "declared": {"type": "array", "items": {"type": "object", "required": ["name", "source", "digest"], "properties": {"name": {"type": ["string", "null"]}, "source": {"type": ["string", "null"]}, "digest": {"oneOf": [{"type": "null"}, {"$ref": "#/$defs/digest"}]}, "files": {"type": "integer"}, "pinned": {"type": "boolean"}}}},
        "effective": {"oneOf": [{"type": "null"}, {"type": "array", "items": {"type": "object", "required": ["name", "digest", "files"], "properties": {"name": {"type": "string"}, "digest": {"$ref": "#/$defs/digest"}, "files": {"type": "integer"}}}}], "description": "The skill directories in the sandbox as the turn started (null: not read)."}
      }
    },
    "plugins": {"description": "Optional (absent from manifests stored before it). Claude Code plugins from the environment's read-only seed (#136; docs/harnesses.md#plugins); null when it declares none. Part of harness_digest.",
      "oneOf": [{"type": "null"}, {"type": "array", "items": {"type": "object",
        "required": ["name", "marketplace", "version", "path", "digest", "enabled", "source"],
        "properties": {
          "name": {"type": "string"}, "marketplace": {"type": ["string", "null"]}, "version": {"type": ["string", "null"]},
          "path": {"type": ["string", "null"], "description": "Where Claude Code loads it from in the sandbox, under the seed (/opt/hangar/claude-seed)."},
          "digest": {"oneOf": [{"type": "null"}, {"$ref": "#/$defs/digest"}], "description": "sha256 over its marketplace entry and its directory as declared (the control plane's copy); Claude Code's writable caches are not inputs."},
          "files": {"type": ["integer", "null"]},
          "enabled": {"type": "boolean"},
          "effective": {"type": "boolean", "description": "Whether hangar's settings load it for this harness, hardened (ask, deny-risky) turns included."},
          "source": {"const": "seed"},
          "git": {"type": "string", "description": "A plugin from a git marketplace (#222): its repo URL, as the spec names it, with no credential."},
          "commit": {"type": ["string", "null"], "description": "With git: the commit it is pinned to, fetched through the git gateway when the image was built."},
          "error": {"type": "string"}}}}]},
    "mcp_servers": {
      "type": "array",
      "items": {
        "type": "object",
        "required": ["name", "transport", "source", "tools"],
        "properties": {
          "name": {"type": "string"},
          "transport": {"enum": ["http", "stdio"]},
          "source": {"type": "string"},
          "url": {"type": "string", "description": "Scheme, host, port and path only: no user info, query or fragment."},
          "headers": {"type": "array", "items": {"type": "string"}, "description": "Header names; values are never included."},
          "command": {"type": ["string", "null"]},
          "args": {"type": "array", "items": {"type": "string"}},
          "env": {"type": "array", "items": {"type": "string"}, "description": "Variable names; values are never included."},
          "tools": {"type": ["array", "null"], "items": {"type": "string"}, "description": "When hangar knows them; null otherwise."}
        }
      }
    },
    "hooks": {"type": "array", "items": {"type": "object", "required": ["event", "source"], "properties": {"event": {"type": "string"}, "source": {"type": "string", "description": "hangar:verify_gate, hangar:tower_hook (KAN-78: the managed Tower hook; docs/governance-hook.md) or environment:<path>."}, "digest": {"type": ["string", "null"], "description": "hangar:tower_hook: sha256 of the hook script hangar installed and checked as the turn started. environment:<path>: of the settings file."}}}},
    "permissions": {
      "type": "object",
      "required": ["approvals", "harness_permission_checks"],
      "properties": {
        "approvals": {"enum": ["auto", "ask", "deny-risky", "governed"]},
        "live": {"type": ["boolean", "null"]},
        "harness_permission_checks": {"enum": ["on", "off"], "description": "off: approvals=auto runs the harness with its permission prompts off."},
        "uid_split": {"type": ["boolean", "null"]},
        "hardened": {"type": ["boolean", "null"], "description": "hangar owned the harness's settings for the turn (harden.py)."},
        "isolation": {"type": ["object", "null"]},
        "sudo": {"type": "boolean", "description": "The environment gives the sandbox user passwordless sudo."},
        "no_new_privs": {"type": ["boolean", "null"], "description": "The sandbox's processes can't gain privileges (sudo fails): GKE pods, and Docker job sandboxes under HANGAR_INTAKE_POSTURE=enforce."}
      }
    },
    "egress": {
      "type": "object",
      "required": ["declared", "default", "effective"],
      "properties": {
        "declared": {"type": ["object", "null"]},
        "default": {"type": "string"},
        "effective": {"type": ["object", "null"], "description": "What the sandbox got: mode, enforced, mirrors. An eval sandbox's also has eval (closed: hangar's default, no GitHub host; environment: its own), github, and warnings when GitHub is reachable (KAN-234)."},
        "floor": {"enum": ["allowlist", "none"], "description": "Present when the sandbox's egress was raised above its environment's for an intake-triggered job (HANGAR_INTAKE_POSTURE=enforce)."},
        "floor_reason": {"type": "string", "description": "Why the floor applies: intake, or eval (an eval job whose environment sets no egress gets the closed allowlist, KAN-234)."}
      }
    },
    "environment": {
      "type": "object",
      "required": ["name", "image", "spec_digest", "provider", "build", "services", "secret_env"],
      "properties": {
        "name": {"type": "string"},
        "image": {"type": ["string", "null"]},
        "spec_digest": {"$ref": "#/$defs/digest"},
        "provider": {"type": ["string", "null"]},
        "tier": {"type": ["string", "null"]},
        "runtime_class": {"type": ["string", "null"]},
        "build": {"type": ["object", "null"], "description": "The repo's environment build the sandbox started from (#93): version, id, commit, config_digest, fallback, reason, stale."},
        "services": {"type": "array", "items": {"type": "object", "required": ["name", "image"]}},
        "secret_env": {"type": "array", "items": {"type": "string"}, "description": "Names of variables that hold a secret. Never values."}
      }
    },
    "resources": {
      "type": "object",
      "required": ["cpus", "memory_gb"],
      "properties": {"cpus": {"type": "number"}, "memory_gb": {"type": "number"}, "disk_gb": {"type": ["number", "null"]}}
    },
    "policy": {
      "type": "object",
      "properties": {
        "turn_budget": {"type": ["object", "null"]},
        "job_budget": {"type": ["object", "null"]},
        "credential_mode": {"type": ["string", "null"]},
        "verify": {"type": ["string", "null"]},
        "deliver": {"type": ["string", "null"]}
      }
    },
    "repo": {"type": "object", "properties": {"url": {"type": ["string", "null"]}, "base_branch": {"type": ["string", "null"]}, "base_sha": {"type": ["string", "null"]}, "branch": {"type": ["string", "null"]}, "isolation": {"type": "object", "description": "KAN-234: an experiment trial's clone, stopped at its base: the refs left, no remotes, and its git grant (closed, or direct when the gateway isn't in use).", "properties": {"base_sha": {"type": ["string", "null"]}, "branch": {"type": "string"}, "refs": {"type": "array", "items": {"type": "string"}}, "remotes": {"type": "array", "items": {"type": "string"}}, "git_grant": {"enum": ["closed", "direct"]}}}}},
    "probe": {"type": ["object", "null"], "properties": {"ok": {"type": "boolean"}, "error": {"type": ["string", "null"]},
      "plugins": {"description": "The declared seed plugins' directories in the sandbox as the turn started (#136; null: none declared, or not read).",
        "oneOf": [{"type": "null"}, {"type": "array", "items": {"type": "object", "required": ["id", "present"], "properties": {
          "id": {"type": "string"}, "present": {"type": "boolean"}, "digest": {"oneOf": [{"type": "null"}, {"$ref": "#/$defs/digest"}]},
          "files": {"type": "integer"}}}}]},
      "codex_hooks": {"description": "The harness lock's Codex plugin hooks as the sandbox's Codex reported them as the turn started (#244; null: none, or not read). key '-': Codex couldn't be asked (status says why).",
        "oneOf": [{"type": "null"}, {"type": "array", "items": {"type": "object", "required": ["key", "status", "enabled"], "properties": {
          "key": {"type": "string"}, "status": {"type": "string", "description": "trusted, untrusted, modified, missing, error, no-python3"},
          "enabled": {"type": "boolean"}}}}]}}},
    "warnings": {"type": "array", "items": {"type": "string"}}
  },
  "$defs": {
    "digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"},
    "file": {"type": "object", "required": ["bytes", "digest"], "properties": {"path": {"type": "string"}, "bytes": {"type": "integer", "minimum": 0}, "digest": {"$ref": "#/$defs/digest"}}},
    "text": {"type": "object", "required": ["digest", "bytes"], "properties": {"source": {"type": "string"}, "digest": {"$ref": "#/$defs/digest"}, "bytes": {"type": "integer", "minimum": 0}, "text": {"type": "string"}, "truncated": {"type": "boolean"}}}
  }
}
