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

# JobSpec

> CLI 构造、服务端准入的 forge/v2 载荷——用于读懂一次拒绝，或者自己写工具。

```bash theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}}
sf submit my-grpo --profile h200:8
cat .starforge/jobspec.json
```

JobSpec 不用手写，`sf submit` 会把它构造进作业包。什么时候读这一页：
调试一次被拒的提交、写一个会提交作业的工具，或者通过 MCP 操作。

`apiVersion` 必须是 `forge/v2`，`kind` 必须是 SDK 提供的训练 kind（`KIND_TRAINING`）。
其他取值在客户端和服务端都会被拒绝，而且发生在任何调度动作之前。

## 形状

```json theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}}
{
  "apiVersion": "forge/v2",
  "kind": "TrainingJob",
  "metadata": {
    "name": "run-id-segment",
    "project": "my-lab",
    "owner": "",
    "labels": {}
  },
  "spec": {
    "recipe": { "name": "nemo-rl/grpo", "version": "…", "digest": "sha256:…" },
    "source": { "exp": "experiments/my-grpo" },
    "framework": {
      "kind": "nemo-rl",
      "version": "0.7.0",
      "runtime_id": "…",
      "image": "",
      "observability_url": ""
    },
    "resources": {
      "pools": [{ "name": "train", "series": "h200", "nodes": 1, "gpus_per_node": 8 }],
      "roles": { "trainer": "train" }
    },
    "model": { "base": "", "init_from": "run/<id>/checkpoint" },
    "data": {
      "train": { "dataset": "alice/gsm8k-zh@v2", "path": "train.parquet" },
      "corpus": "alice/handbook@v1"
    },
    "hyperparams": { "policy.optimizer.kwargs.lr": 1e-6 },
    "lifecycle": { "on_success": ["export"] },
    "plugins": [{ "id": "alice/opsd-patch", "version": "1.2.0", "digest": "sha256:…" }]
  },
  "provenance": {
    "git_commit": "…",
    "git_dirty": false,
    "config_sha": "…",
    "core_version": "0.3.2"
  }
}
```

## 字段规则

这里的每一条都是因为有提交被它拒绝过才写下来的。按字段分组，不按「有多意外」分组。

| 字段                                 | 规则                                                                   |
| ---------------------------------- | -------------------------------------------------------------------- |
| `metadata.owner`                   | 客户端传什么都会被忽略，服务端盖上已认证的账号                                              |
| `spec.source.entrypoint`           | 必须为空。entrypoint 归 recipe 所有；要跑自己的命令，用 `custom/custom` recipe         |
| `spec.framework.image`             | 可选的 `--image` 覆盖。custom 作业必填。仓库主机必须在部署的允许列表里                         |
| `spec.framework.observability_url` | recipe 声明 `external` 可观测性时必填，声明 `platform` 时禁止出现                     |
| `spec.model.init_from`             | `run/<run_id>/<kind>`，可选加 `@step=<N>`                                |
| `spec.data.corpus`                 | `<owner>/<name>[@version]`。平台挂载它并设置 `DOCS_DIR`。写死宿主机路径会绕过权限检查，因此不被接受 |
| `spec.resources.pools`             | 不得为空。每个池的 GPU 数是 `nodes * gpus_per_node`，跨池求和用于配额校验                  |
| `spec.plugins[].id`                | `<owner>/<name>`。digest 以 `sha256:` 开头。两个会解包到同名叶子目录的插件不能同时使用         |

## 握手

准入会拿四样东西和服务端 catalog 比对：recipe 名、版本、digest，以及 `framework.runtime_id`。
任何一项不一致都会被拒。

这正是 `sf recipe status` 报告、`sf recipe upgrade` 修复的东西。
provenance 里的 `core_version` 还必须满足锁文件上的 `requires.core` 范围——
旧 CLI 对着更新的 catalog 提交会被拒，错误里会同时点出两个版本。
