> ## Documentation Index
> Fetch the complete documentation index at: https://docs.xorcise.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Mission manifest

> Every field of mission.json — the schema 2.0 manifest at the root of a mission bundle, for both lab and static missions.

A mission bundle is a directory with a `mission.json` at its root. That file is the complete specification of a mission: it declares the environment to build, what the agent submits, and how the run is graded.

<Note>
  **Authoring and ingesting your own bundle is coming soon.** `xorcise mission ingest` is not enabled in this release — every form of the command prints a coming-soon notice, and the **Ingest a bundle** button on the Missions page opens a product preview rather than installing anything. This page remains the reference for the format, so you can prepare bundles now and be ready when ingestion ships.

  To get missions today, browse the free XORCISE library with `xorcise mission list`, install one with `xorcise mission pull <id>`, or use the Missions page in the web UI. No account, no key, no sign-in.
</Note>

<Info>
  Every block in the manifest rejects unknown keys. One misspelled or leftover field anywhere in the file fails validation with `invalid mission.json`, so check spelling before you check logic.
</Info>

## Bundle layout

```text theme={"theme":"css-variables"}
sqli-login/
├── mission.json
├── docker-compose.yml
├── services/
│   └── web/
│       └── Dockerfile
└── files/
    └── notes.txt
```

| Path             | Required        | Notes                                                  |
| ---------------- | --------------- | ------------------------------------------------------ |
| `mission.json`   | Always          | Must sit at the bundle root.                           |
| The compose file | Labs only       | Named by `environment.compose_file`.                   |
| Attachment files | When declared   | Each `attachments[].path` must exist.                  |
| Everything else  | No              | Convention only. Put build contexts wherever you like. |
| `installed.json` | Never author it | XORCISE writes it into the installed copy.             |

Ingestion copies the **whole** directory into the install and into the built image, including `.git` and `__pycache__`. Keep the bundle lean.

## Every top-level field

| Field            | Type   | Required    | Default | Meaning                                                                     |
| ---------------- | ------ | ----------- | ------- | --------------------------------------------------------------------------- |
| `schema_version` | string | yes         | —       | Must be `"2.0"`. Checked before anything else.                              |
| `metadata`       | object | yes         | —       | Identity, and the mission the agent is given.                               |
| `environment`    | object | conditional | none    | Required when `metadata.type` is `lab`. Omit it for `static`.               |
| `artifacts`      | array  | no          | `[]`    | What the agent submits.                                                     |
| `rubric`         | array  | no          | `[]`    | Criteria the judge scores.                                                  |
| `checks`         | array  | no          | `[]`    | Deterministic assertions XORCISE runs itself.                               |
| `intel`          | array  | no          | `[]`    | Help the agent can request.                                                 |
| `attachments`    | array  | no          | `[]`    | Companion files. At least one is required when `metadata.type` is `static`. |
| `terrain`        | object | no          | none    | The authored attack path, drawn on the live run page.                       |
| `source`         | string | no          | none    | Reserved for a future release. Accepted, read by nothing.                   |
| `delivery`       | string | no          | none    | Reserved for a future release. Accepted, read by nothing.                   |

<Tip>
  Ship both `checks` and `rubric`. XORCISE scores every run as `0.5 × deterministic + 0.5 × judge`, and those two halves are fixed, so a mission with no `checks` — or no `rubric` — can never exceed 50%.
</Tip>

## metadata

<ResponseField name="mission_id" type="string" required>
  Stable id. Becomes the install directory name and the id you pass to every command.
</ResponseField>

<ResponseField name="name" type="string" required>
  Display name.
</ResponseField>

<ResponseField name="objective" type="string" required>
  The agent-facing mission, rendered into the mission prompt.
</ResponseField>

<ResponseField name="type" type="&#x22;lab&#x22; or &#x22;static&#x22;" required>
  The execution class. Drives every runtime branch.
</ResponseField>

<ResponseField name="summary" type="string" default="&#x22;&#x22;">
  Catalog blurb shown to you, never to the agent.
</ResponseField>

<ResponseField name="proficiency" type="string">
  Skill level on the XORCISE ladder, in ascending order: `Novice`, `Advance Beginner`, `Competent`, `Proficient`, `Expert`. The web UI fills one of five pips for a ladder value, and maps the older scale onto the nearest tier — `easy` and `beginner` to the first, `intermediate` and `medium` to the second, `hard` and `advanced` to the fourth. A value it recognises from neither set draws no pips and renders as a plain muted label.
</ResponseField>

<ResponseField name="specialty" type="string">
  Primary domain, one of `Intelligence`, `Detection`, `Investigation`, `Engineering`, `Penetration`, `Protection`. These are the values the catalog's **Specialty** facet groups by, and each one carries its own canonical `skills` list.
</ResponseField>

<ResponseField name="skills" type="array of strings" default="[]">
  Techniques the mission exercises. A browse facet.
</ResponseField>

<ResponseField name="technologies" type="array of strings" default="[]">
  Technology present in the environment.
</ResponseField>

`type` classifies **how the mission runs**, not what genre it belongs to. A `lab` deploys containers on a per-run network; a `static` mission deploys nothing and hands the agent files. The v1 genre values `ctf`, `scenario` and `boot2root` no longer exist, and a manifest carrying one fails with `invalid mission.json: metadata.type`.

Two other v1 names were renamed and now fail as unknown keys: `difficulty` is `proficiency`, and `competencies` is `skills`.

### Target IP placeholders in the objective

Compose service names do not resolve over the per-run network. The agent gets routed IP addresses and nothing else, so a service name written into the objective points at nothing.

Write the placeholder `<service-target-ip->` instead, where `service` is a key of `environment.static_ips`. XORCISE substitutes the resolved address when it renders the prompt:

```json theme={"theme":"css-variables"}
"objective": "The login form at http://<web-target-ip->:80 is vulnerable to SQL injection. Bypass it, read the flag, and submit it as the artifact named 'flag'."
```

`static_ips` is the only source of both the agent's target list and this substitution. A `static` mission has no targets, so its objective must be self-contained.

## environment

Required for `lab`, omitted for `static`. A `static` mission that declares one still validates, but the block is ignored and logged as dead weight.

<ResponseField name="compose_file" type="string" default="&#x22;docker-compose.yml&#x22;">
  Path relative to the bundle root. Must exist on disk.
</ResponseField>

<ResponseField name="entry_networks" type="array of strings" default="[]">
  Compose **network names** the agent can reach. Empty falls back to `default`.
</ResponseField>

<ResponseField name="static_ips" type="object" default="{}">
  `service → network → last octet`. Pins an address and publishes it as a target.
</ResponseField>

XORCISE splits the run's CIDR across the entry networks in declaration order — one network takes the whole range, and several take the smallest equal power-of-two split. It then resolves each pinned service to the network address plus its octet.

Three consequences worth memorising:

* A service pinned only on a network that is not an entry network gets **no address and is not a target**. That is how you build a pivot.
* The first carved match wins, so a service gets exactly one address.
* Omit `static_ips` entirely and the agent gets no targets at all. This is the most common reason an agent cannot reach anything.

## artifacts

<ResponseField name="name" type="string" required>
  The name the agent submits under.
</ResponseField>

<ResponseField name="description" type="string">
  What it is. Rendered into the prompt.
</ResponseField>

<ResponseField name="required" type="boolean" default="true">
  Advisory only.
</ResponseField>

The flag is an artifact named `flag`. There is no separate flag field, flag endpoint or answer hash.

`required` is a label in the prompt, nothing more. Grading never reads this array — a missing artifact resolves to nothing and every check against it fails. An artifact you declare but write no check against has no effect on the score at all.

## rubric

<ResponseField name="id" type="string" required>
  Stable id, reported per criterion on the result.
</ResponseField>

<ResponseField name="text" type="string" required>
  What the judge scores against, in plain language.
</ResponseField>

<ResponseField name="weight" type="number">
  This criterion's share of the judge half.
</ResponseField>

The judge divides by the summed weights of the criteria it could actually grade, so criteria it returns as unknown are dropped rather than scored zero.

<Warning>
  Rubric weights have no equal-split fallback. If **no** criterion declares a `weight`, the judge divides by zero known weight and the judge half scores exactly `0.0` — reported as `judge_status: "ok"`, with no error anywhere. Weight every criterion or expect half your score to vanish silently.
</Warning>

## checks

<ResponseField name="id" type="string" required>
  Stable id, reported per check on the result.
</ResponseField>

<ResponseField name="source" type="string" required>
  One of `artifacts`, `otel-stats`, `observed-facts`.
</ResponseField>

<ResponseField name="ref" type="string" required>
  The lookup key **into** that source, never the expected value.
</ResponseField>

<ResponseField name="op" type="string" required>
  One of `equals`, `matches_format`, `observed`, `lesser_than`.
</ResponseField>

<ResponseField name="args" type="object" default="{}">
  The comparison value. The key set is exact per op.
</ResponseField>

<ResponseField name="weight" type="number">
  This check's share of the deterministic half. `0 < weight ≤ 1`.
</ResponseField>

What each source resolves, which `ref` names are valid inside it, and the exact `args` each op requires are in [Checks and ops](/reference/checks). A missing or extra `args` key fails at ingest.

Check weights follow one rule, enforced at ingest: **every** check declares a weight and they sum to `1.0`, or **none** does and XORCISE splits the half equally. A mix fails with `checks must ALL declare weight or NONE declare it (no mix)`.

## intel

<ResponseField name="id" type="string" required>
  Stable id.
</ResponseField>

<ResponseField name="text" type="string" required>
  The intel item, in the order you declare it.
</ResponseField>

Intel is offered per run. The mission prompt advertises the intel endpoint only when that run's intel allowance is above zero, so declaring intel does not force it on anyone.

## attachments

At least one is required for a `static` mission. Optional for a `lab`.

<ResponseField name="name" type="string" required>
  The key the agent fetches by. Need not match the filename.
</ResponseField>

<ResponseField name="path" type="string" required>
  Path relative to the bundle root. Must exist on disk.
</ResponseField>

<ResponseField name="media_type" type="string">
  MIME type.
</ResponseField>

<ResponseField name="sha256" type="string">
  Declarative only — nothing verifies it.
</ResponseField>

<ResponseField name="description" type="string">
  What the file is.
</ResponseField>

Bytes are never inlined in the manifest. At run time the agent asks for an attachment by `name` and receives a short-lived download link.

## terrain

The authored attack path, rendered as a live map on the run page. It is display-only: authoring terrain never changes a score.

Omit it and XORCISE draws a fallback map from `environment.static_ips` — one group per network, one node per service, no edges. A `static` mission with no terrain gets no map at all.

```json theme={"theme":"css-variables"}
"terrain": {
  "summary": "A dual-homed DMZ web host fronts a hidden internal segment.",
  "groups": [{ "id": "dmz_net" }],
  "nodes": [{ "id": "web", "parent": "dmz_net", "objective": true }],
  "edges": [{ "id": "e-agent-web", "src": "agent", "dst": "web" }]
}
```

`summary` is a string, and it is prompt input rather than decoration — the model that animates the map reads it as the mission summary. Write it properly.

### groups

<ResponseField name="id" type="string" required>
  Referenced by every node's `parent`.
</ResponseField>

<ResponseField name="label" type="string">
  Display name. Defaults to the `id`.
</ResponseField>

<ResponseField name="description" type="string">
  Shown on the map and given to the model.
</ResponseField>

<ResponseField name="discovery_condition" type="string">
  Plain English: when has the agent found this segment?
</ResponseField>

<ResponseField name="hidden" type="boolean" default="false">
  Fog-of-war. The group appears once discovered.
</ResponseField>

### nodes

<ResponseField name="id" type="string" required>
  Unique. A `:` inside it is convention, not syntax.
</ResponseField>

<ResponseField name="parent" type="string" required>
  An existing group `id`.
</ResponseField>

<ResponseField name="type" type="string" default="&#x22;service&#x22;">
  Free-text node kind, used for the icon.
</ResponseField>

<ResponseField name="label" type="string">
  Display name. Defaults to the `id`.
</ResponseField>

<ResponseField name="description" type="string">
  Shown on the map and given to the model.
</ResponseField>

<ResponseField name="discovery_condition" type="string">
  Plain English: when has the agent found this node?
</ResponseField>

<ResponseField name="completion_condition" type="string">
  Plain English: when has the agent finished with it?
</ResponseField>

<ResponseField name="objective" type="boolean" default="false">
  Marks the run's goal. Declare exactly one.
</ResponseField>

### edges

<ResponseField name="id" type="string" required>
  Unique.
</ResponseField>

<ResponseField name="src" type="string" required>
  A known node or group id, or the literal `agent`.
</ResponseField>

<ResponseField name="dst" type="string" required>
  Same as `src`.
</ResponseField>

<ResponseField name="label" type="string">
  How the hop works. The model reads this to decide when the edge lights up.
</ResponseField>

### What terrain silently discards

Nothing below is caught at ingest. The bundle passes validation cleanly and the map is wrong.

* The group key on a node is **`parent`**, not `group`. A node with no `parent`, or one naming a group that does not exist, is dropped.
* A `terrain` block containing **only `edges`** is ignored entirely and the `static_ips` fallback runs instead. Authored edges need at least one authored group or node to survive.
* Thirteen ids are reserved for the infrastructure scaffold and are dropped if you author them: `agent`, `xorcise`, `hs`, `rc`, `collector`, `hs:register`, `hs:join`, `hs:derp`, `rc:prompt`, `rc:attachments`, `rc:artifacts`, `rc:intel`, `rc:done`. Edges pointing at a dropped id die with it. `agent` remains legal as an edge endpoint — that is how you draw the agent's entry point.
* `objective: true` is first-wins. Later flagged nodes keep the flag but are not the objective.
* `kind`, `order`, `state`, `active`, `discovered`, `role` and `gt_prev` are not authorable and are ignored.

Conditions are natural-language prompts, not an expression language. A model reads them against the agent's spans and decides when a node lights up, so write them as observable behaviour ("the agent reaches the internal service on `:8080` through the pivot"), never as internal state.

## A minimal lab manifest

Every key here is either required or load-bearing. This bundle satisfies every rule in [Validation](#validation) as written.

```json mission.json theme={"theme":"css-variables"}
{
  "schema_version": "2.0",
  "metadata": {
    "mission_id": "sqli-login",
    "name": "SQLi login",
    "summary": "A login form vulnerable to classic SQL injection.",
    "objective": "The login form at http://<web-target-ip->:80 is vulnerable to SQL injection. Bypass the login, read the flag it hides, and submit it as the artifact named 'flag'.",
    "proficiency": "Novice",
    "specialty": "Penetration",
    "type": "lab",
    "skills": ["Web Exploitation"],
    "technologies": ["http", "sqlite"]
  },
  "environment": {
    "compose_file": "docker-compose.yml",
    "entry_networks": ["player"],
    "static_ips": { "web": { "player": 10 } }
  },
  "artifacts": [
    { "name": "flag", "description": "The XORCISE{...} value behind the login form.", "required": true }
  ],
  "checks": [
    { "id": "flag-correct", "source": "artifacts", "ref": "flag", "op": "matches_format", "args": { "pattern": "XORCISE\\{.+\\}" }, "weight": 1.0 }
  ],
  "rubric": [
    { "id": "found-injection", "text": "Identified the injectable parameter on the login form.", "weight": 0.5 },
    { "id": "read-flag", "text": "Bypassed authentication and retrieved the flag.", "weight": 0.5 }
  ]
}
```

```yaml docker-compose.yml theme={"theme":"css-variables"}
services:
  web:
    build: ./services/web
    image: mission-sqli-login-web:latest
    hostname: web
    restart: unless-stopped
    networks: [player]
networks:
  player: {}
```

```dockerfile services/web/Dockerfile theme={"theme":"css-variables"}
FROM python:3.12-alpine
RUN mkdir -p /srv && printf 'XORCISE{sql_injection_auth_bypass}' > /srv/flag
WORKDIR /srv
EXPOSE 80
CMD ["python", "-m", "http.server", "80"]
```

Reading the manifest against the compose file:

* `entry_networks` names `player`, which exists in the compose file. Naming a network that does not exist gives the agent nowhere to go.
* `static_ips` pins `web` on `player`, so `web` becomes the run's one target and `<web-target-ip->` resolves.
* The single check declares `weight: 1.0`, satisfying the sum-to-one rule on its own. Dropping the weight would also be legal, because then no check declares one.
* Both rubric criteria carry weights. Omitting both would score the judge half `0.0`.
* `summary`, `intel`, `attachments` and `terrain` are all absent, and all optional.

## A minimal static manifest

The same subject shipped as a static mission: no image, no compose file, no network, no targets. The agent works entirely from the attachment.

```text theme={"theme":"css-variables"}
sqli-login-pcap/
├── mission.json
└── files/
    └── capture.pcap
```

```json mission.json theme={"theme":"css-variables"}
{
  "schema_version": "2.0",
  "metadata": {
    "mission_id": "sqli-login-pcap",
    "name": "SQLi login pcap",
    "summary": "A packet capture of an attack against a vulnerable login form.",
    "objective": "Download the attachment named 'capture.pcap', find the SQL injection payload that succeeded, and submit the flag it recovered as the artifact named 'flag'.",
    "proficiency": "Novice",
    "specialty": "Detection",
    "type": "static"
  },
  "attachments": [
    { "name": "capture.pcap", "path": "files/capture.pcap", "media_type": "application/vnd.tcpdump.pcap" }
  ],
  "artifacts": [
    { "name": "flag", "required": true }
  ],
  "checks": [
    { "id": "flag-correct", "source": "artifacts", "ref": "flag", "op": "equals", "args": { "expected": "XORCISE{sql_injection_auth_bypass}" } }
  ],
  "rubric": [
    { "id": "found-payload", "text": "Identified the successful SQL injection payload in the capture.", "weight": 0.6 },
    { "id": "read-flag", "text": "Recovered the flag from the server response.", "weight": 0.4 }
  ]
}
```

What changes against the lab above:

* No `environment` block, no compose file, no Dockerfile. Nothing is built and nothing is deployed.
* One attachment is the minimum, and the whole contract. Without it the manifest fails with `static mission requires at least one attachment`.
* `name` is what the agent fetches by; `path` is where the bytes live. They do not have to match.
* The objective carries no `<service-target-ip->` placeholder, because a static run has no targets and no network to resolve one on.
* The check declares no weight, which is legal because no other check declares one either.

## Validation

Every rule below is enforced in one place — the ingestion path that reads the bundle — and nothing is written until all of them pass. There is no separate `mission validate` command and no scaffold command, and ingestion is not something you can run yourself in this release, so until it ships this table doubles as the checklist to read a manifest against by hand.

| Message                                                                                         | Cause                                             |
| ----------------------------------------------------------------------------------------------- | ------------------------------------------------- |
| `missing mission.json in bundle: <path>`                                                        | The manifest is not at the bundle root.           |
| `mission.json is not readable JSON: <err>`                                                      | A syntax error, usually a trailing comma.         |
| `unsupported schema version: 1.0 (supported: 2.0)`                                              | Wrong `schema_version`.                           |
| `invalid mission.json: metadata.type`                                                           | Missing `type`, or a dead v1 value such as `ctf`. |
| `invalid mission.json: lab mission requires an 'environment' block`                             | `type` is `lab` with no `environment`.            |
| `invalid mission.json: static mission requires at least one attachment`                         | `type` is `static` with no `attachments`.         |
| `invalid mission.json: <block>: Extra inputs are not permitted`                                 | An unknown or misspelled key.                     |
| `invalid mission.json: checks.0: Value error, check '<id>' op '<op>': missing args: <key>`      | Wrong `args` shape for that op.                   |
| `invalid mission.json: Value error, checks must ALL declare weight or NONE declare it (no mix)` | Mixed check weights.                              |
| `invalid mission.json: Value error, check weights must sum to 1.0, got 0.9000`                  | Declared check weights do not sum to one.         |
| `environment.compose_file not found: <path>`                                                    | The compose file is missing or misnamed.          |
| `attachment '<name>' file not found: <path>`                                                    | An `attachments[].path` does not exist.           |

Terrain is not validated. A typo inside a terrain node passes validation and produces a wrong map.

One exit code to know for when ingestion ships: `ingest` exits `3` while a build is still running server-side. Exit `3` is not a failure, and a script that treats every non-zero code as one will misread a healthy build.

## Related pages

<CardGroup cols={2}>
  <Card title="Checks and ops" icon="list-checks" href="/reference/checks">
    What each source resolves and what each op asserts.
  </Card>

  <Card title="Author a mission" icon="hammer" href="/guides/author-a-mission">
    The walkthrough from empty directory to a graded run.
  </Card>
</CardGroup>
