Ctrl K
ComunidadGuías…

docs/projects-yaml.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# `projects.yaml` — per-project priorities, defaults, and roots

Sections 1–9 are the original design notes for the per-project `defaults:`
feature (2026-07) and are kept as the rationale record: their `path:line`
references and effort estimates are historical, and submit now lives in
`src/jobd/broker/submit.py` with the precedence cascade in
`src/jobd/config.py:resolve_effective_config`. Section 10 is the current
specification for `roots:` (cwd-derived project identity, shipped v0.5.42).
Read alongside the live source: `src/jobd/config.py`, `src/jobd/models.py`,
`src/job_cli/cli.py`.

---

## 1. File location and format

### Location

`projects.yaml` already lives on the broker host at the path passed to
`build_app(projects_path=...)`. In production this resolves to
`/app/config/projects.yaml` (controlled by `JOBD_CONFIG_DIR`, read in `main.py`).
In the Docker stack this maps to `/srv/jobd/config/projects.yaml`
on the broker host, bind-mounted into the container.

There is no client-side copy. Per-project defaults are broker-side only.
Rationale: the broker is the single source of truth for job configuration; a
client-side copy would diverge between machines (laptop / desktop) and make
`--explain` unreliable without a second sync protocol.

The file does not move. Extend the existing schema in place.

### Schema

Extend each project entry with an optional `defaults:` block. All keys under
`defaults` are optional; absence means "fall through to global constant."

```yaml
# /app/config/projects.yaml

projects:
  project-c:
    priority: 55
    defaults:
      max_wall_s: 14400 # 4h; project-c GPU runs are long
      idle_timeout_s: 1800 # 30m; hang guard
      host_pin: desktop # always route to the GPU box
      requires:
        gpu: true
        needs: ["cuda"]
      preemptible: true
      priority: 55 # redundant with top-level but explicit
      escalate_to_arc: false

  project-a:
    priority: 55
    defaults:
      max_wall_s: 28800 # 8h; LoRA runs
      idle_timeout_s: 3600 # 1h
      host_pin: desktop
      requires:
        gpu: true
        needs: ["cuda"]
      preemptible: true
      escalate_to_arc: false

  project-b:
    priority: 80
    defaults:
      max_wall_s: 86400 # 24h; RNA-seq pipelines
      idle_timeout_s: 7200 # 2h
      host_pin: any
      requires:
        needs: ["R"]
      preemptible: false
      escalate_to_arc: true # future ARC routing; ignored until ARC backend ships

  _default:
    priority: 40
    # no defaults block: global constants apply
```

### Editing

Hand-edit the file directly. CLI read-back via `job projects list` (existing
command) is extended to show defaults when present. No `job projects
set-default` command in this iteration — hand-edit is sufficient and keeps
the CLI surface small. A `POST /reload` call or broker restart picks up
edits.

For programmatic updates (future scripts), `job reload` sends `POST /reload`
(existing endpoint, `app.py:526`).

### Versioning

The file is checked into the repo at `config/projects.yaml` and bind-mounted
into the Docker container. Changes go through git on the broker host. Same pattern as
`profiles.yaml` and `classifier.yaml`.

---

## 2. Schema — exhaustive key list

All keys live under `projects.<name>.defaults`. The block is optional per
project; absent keys fall through to the global constant.

| Key               | Type           | Global default                               | Semantic                                              | Overrides which flag/field                                  |
| ----------------- | -------------- | -------------------------------------------- | ----------------------------------------------------- | ----------------------------------------------------------- |
| `max_wall_s`      | `int \| null`  | `null` (no limit)                            | Kill job after N wall-clock seconds                   | `--max-wall` / `JobSubmit.max_wall_s`                       |
| `idle_timeout_s`  | `int \| null`  | `null` (no limit)                            | Kill job after N seconds with no output               | `--idle-timeout` / `JobSubmit.idle_timeout_s`               |
| `host_pin`        | `str \| null`  | `"any"`                                      | Pin to named worker                                   | `--host` / `JobSubmit.host_pin`                             |
| `requires`        | `dict \| null` | `null`                                       | Capability requirements (same shape as `JobRequires`) | `--needs`, `--gpu`, `--arch`, `--os` / `JobSubmit.requires` |
| `requires.arch`   | `str`          | `"any"`                                      | Worker arch constraint                                | sub-field                                                   |
| `requires.os`     | `str`          | `"any"`                                      | Worker OS constraint                                  | sub-field                                                   |
| `requires.gpu`    | `bool \| null` | `null`                                       | GPU requirement                                       | `--gpu/--no-gpu`                                            |
| `requires.needs`  | `list[str]`    | `[]`                                         | Capability tags                                       | `--needs`                                                   |
| `preemptible`     | `bool \| null` | `null` → treated as `false`                  | Whether broker may preempt this job                   | `--preemptible` / `JobSubmit.preemptible`                   |
| `priority`        | `int \| null`  | `null` → inherits `projects.<name>.priority` | Base priority (0–100)                                 | `--priority-delta` base; same as top-level `priority`       |
| `escalate_to_arc` | `bool`         | `false`                                      | ARC HPC routing eligibility                           | future `--host arc` routing                                 |

Schema layout rationale: flat under `defaults` (not nested per-feature) to
match the existing `profiles.yaml` `ProfileSpec` pattern and keep YAML
iteration simple in the loader. The `requires` sub-object is the only
nested key, for the same reason `JobRequires` is a separate model — it
groups the four capability selectors that are already treated as a unit
elsewhere.

`escalate_to_arc` is parsed and stored but the matcher does not act on it
until the ARC backend ships. Its presence in the schema now means no
migration is needed when ARC lands.

---

## 3. Resolution order

From highest to lowest precedence:

1. **Explicit CLI flag** — any flag the user passes explicitly overrides
   everything.
2. **Project default** — from `projects.yaml: <project>.defaults.*`
3. **Profile default** — from `profiles.yaml` (already implemented; sits
   between project defaults and global constants).
4. **Global constant** — broker-level hardcoded fallback.

For `host_pin` specifically, the existing profile `host_hint` (applied at
`app.py:130-131`) is treated as equivalent to the project default in
precedence. When both are present, project default wins over profile hint.

### Walk-through example

Command: `job submit --project project-c -- ./run.sh`

With `project-c.defaults` set to `max_wall_s: 14400`, `idle_timeout_s: 1800`,
`host_pin: desktop`, `requires: {gpu: true, needs: [cuda]}`,
`preemptible: true`:

| Field            | CLI value        | Project default | Profile default | Global  | **Effective** | Source          |
| ---------------- | ---------------- | --------------- | --------------- | ------- | ------------- | --------------- |
| `max_wall_s`     | `null`           | `14400`         | —               | `null`  | **14400**     | project default |
| `idle_timeout_s` | `null`           | `1800`          | —               | `null`  | **1800**      | project default |
| `host_pin`       | `"any"`          | `"desktop"`     | —               | `"any"` | **"desktop"** | project default |
| `requires.gpu`   | `null`           | `true`          | —               | `null`  | **true**      | project default |
| `requires.needs` | `[]`             | `["cuda"]`      | —               | `[]`    | **["cuda"]**  | project default |
| `preemptible`    | `null`           | `true`          | —               | `false` | **true**      | project default |
| `priority`       | base=55, delta=0 | —               | —               | —       | **55**        | existing logic  |

Effective `JobSubmit` body the CLI sends (before broker resolution):

```json
{
  "cmd": ["./run.sh"],
  "cwd": "/current/working/dir",
  "project": "project-c",
  "host_pin": "any",
  "priority_delta": 0,
  "requires": null,
  "max_wall_s": null,
  "idle_timeout_s": null
}
```

After broker applies project defaults, the Job row is written with:

```json
{
  "host_pin": "desktop",
  "preemptible": true,
  "requires": {
    "gpu": true,
    "needs": ["cuda"],
    "arch": "any",
    "os": "any",
    "idempotent": false
  },
  "max_wall_s": 14400,
  "idle_timeout_s": 1800,
  "priority": 55
}
```

---

## 4. Where in the codebase resolution happens

### Decision: broker-side resolution

Resolve in `app.py:submit` (the `POST /submit` handler), immediately after
the existing `resolve_priority` call at line 128 and before the `host_pin`
override block at lines 129-131.

Rationale: the broker already resolves priority (`resolve_priority`,
line 128), profile (`resolve_profile`, line 124), and host_hint
(lines 130-131) from its own loaded state. Adding project defaults here is
consistent, ensures all clients (CLI, MCP, future REST callers) see the same
defaults, and means the single `POST /reload` endpoint refreshes all of them
together.

CLI-side resolution is insufficient on its own: the MCP server (`server.py`)
submits directly via the broker HTTP client and has no access to a local
`projects.yaml`. Duplicating resolution logic in both places creates drift.

### New data structures: `ProjectEntry` and `ProjectDefaults`

In `config.py`, replace the current `dict[str, int]` loaded by
`load_projects` with a richer structure:

```python
# src/jobd/config.py

@dataclass
class ProjectDefaults:
    max_wall_s: int | None = None
    idle_timeout_s: int | None = None
    host_pin: str | None = None          # None means "do not override"
    requires: JobRequires | None = None
    preemptible: bool | None = None      # None means "do not override"
    priority: int | None = None
    escalate_to_arc: bool = False

@dataclass
class ProjectEntry:
    priority: int
    defaults: ProjectDefaults = field(default_factory=ProjectDefaults)
```

`load_projects` returns `dict[str, ProjectEntry]`. Existing callers that
expect `dict[str, int]` must be updated atomically in the same commit:

- `resolve_priority` at `config.py:72` — extract `.priority` from each entry.
- `app.py:128` — calls `resolve_priority`; no change once the function
  signature is updated.
- `app.py:516-522` (`list_projects`, `set_project_priority`,
  `nudge_project_priority`) — read and mutate `state["projects"]`. Update to
  work with `dict[str, ProjectEntry]`.
- `_persist_projects` at `app.py:648-653` — serialize both `priority` and
  the `defaults` block, preserving any keys that were in the original YAML.
  **This is the highest-priority correctness risk** (see §8).

### New function: `resolve_project_defaults`

Add to `config.py`:

```python
def resolve_project_defaults(
    projects: dict[str, ProjectEntry], project_name: str
) -> ProjectDefaults:
    """The project's own defaults, layered OVER `_default`'s."""
    floor = projects.get("_default")
    entry = projects.get(project_name)
    if entry is None:
        return floor.defaults if floor is not None else ProjectDefaults()
    if floor is None or floor is entry:
        return entry.defaults
    return _merge_defaults(floor.defaults, entry.defaults)
```

**`_default.defaults` is a FLOOR, not a fallback.** Every project inherits it, and
overrides it one key at a time — setting `idle_timeout_s` does not drop
`max_wall_s`. Every field of `ProjectDefaults` therefore uses `None` as its unset
sentinel (including the bools), because a merge cannot otherwise distinguish "the
project said nothing" from "the project said `false`".

This was `projects.get(name) or projects.get("_default")` — an either/or — until
2026-07-14. A project _with_ an entry never saw `_default.defaults`, so the block
`config/projects.yaml` calls "the FLEET-WIDE hang-guard" (`idle_timeout_s`,
`max_wall_s` — the zombie reaper) reached only projects that were **not** configured.
Registering 32 projects to give them priorities disarmed the hang-guard on all 32.
Guarded by `tests/test_project_defaults_floor.py`.

### Changes to `app.py:submit` (lines 121-203)

Insert a block after line 128 (after `priority = resolve_priority(...)`) and
before line 129:

```python
proj_defaults = resolve_project_defaults(state["projects"], req.project)
```

Then update each field resolution to check project defaults in the chain:
CLI value → project default → profile default → global constant.

**`max_wall_s`** (currently at line 187):

```python
max_wall_s = req.max_wall_s
if max_wall_s is None:
    max_wall_s = proj_defaults.max_wall_s
```

**`idle_timeout_s`** (currently at line 188): same shape.

**`host_pin`** (currently lines 129-131):

```python
host_pin = req.host_pin
if host_pin == "any" and proj_defaults.host_pin:
    host_pin = proj_defaults.host_pin
elif host_pin == "any" and profile_spec and profile_spec.host_hint:
    host_pin = profile_spec.host_hint
```

(Project default takes precedence over profile hint.)

**`preemptible`** (currently line 136):

`JobSubmit.preemptible` is `bool = False`, not `bool | None`, so the CLI
always sends `false` when the flag is absent. We cannot distinguish "user
passed `--preemptible false` explicitly" from "user did not pass the flag at
all." Change `JobSubmit.preemptible` to `bool | None = None` and update the
CLI to omit the field from the body when not explicitly passed:

```python
# cli.py: only include preemptible in body if flag was explicitly passed
if preemptible is not None:
    body["preemptible"] = preemptible
```

```python
# app.py: resolution
preemptible = req.preemptible
if preemptible is None:
    preemptible = proj_defaults.preemptible
if preemptible is None and profile_spec:
    preemptible = profile_spec.preemptible
if preemptible is None:
    preemptible = False
```

**`requires`** (currently lines 142-144):

```python
requires = req.requires
if requires is None:
    requires = proj_defaults.requires
if requires is None and profile_spec and profile_spec.requires:
    requires = profile_spec.requires
```

### File reload

`POST /reload` at `app.py:526-530` already calls `load_projects`,
`load_profiles`, and `load_classifier_rules` and stores results in `state`.
No change needed — the updated `load_projects` returning
`dict[str, ProjectEntry]` is picked up automatically.

The broker does NOT watch file mtime. Reload requires `job reload` (new
one-line CLI command calling `POST /reload`) or broker restart. Both are
acceptable; mtime-watching is not worth the complexity for a config that
changes rarely.

---

## 5. `--explain` / dry-run surface

### New endpoint: `POST /resolve`

```python
@app.post("/resolve")
def resolve_job(req: JobSubmit) -> dict:
    """Dry-run submit: return effective resolved config without enqueuing.

    Performs all the same resolution steps as POST /submit (priority,
    profile, project defaults, host_pin, preemptible, requires, max_wall_s,
    idle_timeout_s) but does not write a Job row. Returns the effective
    values and the source of each (cli|project_default|profile|global).
    """
```

Returns a `ResolvedConfig` model (new, in `models.py`):

```python
class FieldResolution(BaseModel):
    value: Any
    source: Literal["cli", "project_default", "profile", "global"]

class ResolvedConfig(BaseModel):
    project: str
    effective_priority: FieldResolution
    effective_host_pin: FieldResolution
    effective_max_wall_s: FieldResolution
    effective_idle_timeout_s: FieldResolution
    effective_preemptible: FieldResolution
    effective_requires: FieldResolution
    effective_escalate_to_arc: FieldResolution
    submit_warning: str | None
```

### `--explain` flag in CLI

```python
explain: bool = typer.Option(False, "--explain", help="print resolved config without submitting")
```

When `--explain` is set, the CLI POSTs `/resolve` instead of `/submit` and
prints the result. No job is created.

Output (human-readable; parseable with `jq` if `--json`):

```
resolved config for project project-c:
  priority:         55              [source: project config]
  host_pin:         desktop         [source: project default]
  max_wall_s:       14400 (4h0m0s)  [source: project default]
  idle_timeout_s:   1800 (0h30m0s)  [source: project default]
  preemptible:      true            [source: project default]
  requires:         gpu=true needs=[cuda]  [source: project default]
  escalate_to_arc:  false           [source: project default]
  submit_warning:   none
```

`--explain` round-trips through the broker so it sees the live
`projects.yaml` state. This is correct: the broker already has resolution
logic, and `--explain` is a transparency tool for the broker's decisions,
not a local preview.

---

## 6. Migration / backward compat

### In-flight jobs

No change. Job rows already store `max_wall_s`, `idle_timeout_s`,
`host_pin`, `preemptible`, `requires_json` at submit time. In-flight jobs
were submitted before project defaults existed and retain their original
values.

### Existing CLI scripts that pass flags explicitly

Continue to work without change. CLI flags (`--max-wall`, `--idle-timeout`,
`--host`, `--preemptible`, `--needs`, `--gpu`) all continue to be honored.
When a flag is supplied, the corresponding `req` field is non-null and the
project default is skipped.

Exception: `--preemptible` requires the sentinel change. `JobSubmit.
preemptible` becomes `bool | None`. Clients that omit the field from the
JSON body get `None` and fall through to project defaults. Clients that
explicitly send `"preemptible": false` (including old CLI versions)
continue to get `false` — no behavior change for them.

### Missing `projects.yaml`

Update `load_projects` to catch `FileNotFoundError`:

```python
try:
    raw = Path(path).read_text()
except FileNotFoundError:
    log.info("no projects.yaml found at %s; using global defaults", path)
    return {"_default": ProjectEntry(priority=40)}
```

### Unknown project name in submit

Do NOT refuse. Submit succeeds with global defaults. Broker emits a
`submit_warning` on the returned `JobInfo`:

```
project 'my-new-project' has no entry in projects.yaml; using global defaults
```

Refusing would break first-time `job submit --project new-experiment`. The
warning is surfaced via the existing `warning` field on `JobInfo`,
concatenated with any other submit-time warnings using the existing
`"; ".join(warnings)` pattern at `app.py:196-198`.

---

## 7. Test plan

### `tests/test_projects_yaml.py`

1. `test_load_projects_full_schema` — YAML with all keys under `defaults`;
   assert all `ProjectDefaults` fields parse correctly.
2. `test_load_projects_missing_defaults_block` — entry with only
   `priority`; assert `defaults` is a zero-valued `ProjectDefaults`.
3. `test_load_projects_missing_file` — `FileNotFoundError` returns
   `{"_default": ProjectEntry(priority=40)}` without raising.
4. `test_load_projects_malformed_yaml` — corrupt YAML raises
   `yaml.YAMLError`, not silent empty dict.
5. `test_load_projects_unknown_keys_in_defaults` — unrecognized key under
   `defaults` is silently dropped (lenient for future additions).
6. `test_persist_projects_round_trip` — write a `ProjectEntry` with
   defaults, call `_persist_projects`, reload, assert defaults survive
   intact. **This is the critical regression test for the
   `_persist_projects` bug.**

### `tests/test_resolution_order.py`

1. `test_cli_flag_wins_over_project_default` — `req.max_wall_s=7200`,
   project default `14400`; effective `7200`.
2. `test_project_default_wins_over_global` — `req.max_wall_s=None`, project
   default `14400`, global `None`; effective `14400`.
3. `test_missing_project_falls_through_to_global` — unknown project; assert
   global defaults applied; assert `job.warning` contains "no entry in
   projects.yaml".
4. `test_host_pin_project_default_overrides_sentinel` —
   `req.host_pin="any"`, project default `"desktop"`; Job row
   `host_pin="desktop"`.
5. `test_host_pin_explicit_cli_not_overridden` — `req.host_pin="laptop"`,
   project default `"desktop"`; Job row `host_pin="laptop"`.
6. `test_preemptible_sentinel_detects_cli_absence` — `req.preemptible=None`,
   project default `True`; Job row `preemptible=True`.
7. `test_preemptible_false_explicit_not_overridden` —
   `req.preemptible=False`, project default `True`; Job row
   `preemptible=False`.
8. `test_requires_project_default_applied` — `req.requires=None`, project
   default `{gpu:true, needs:["cuda"]}`; `requires_json` reflects project
   default.
9. `test_project_default_wins_over_profile_for_host_pin` — project default
   `"laptop"`, profile `host_hint="desktop"`; effective `"laptop"`.

### `tests/test_explain.py`

1. `test_explain_returns_resolved_body_without_job` — `POST /resolve`
   returns expected sources; no Job row created.
2. `test_explain_cli_flag_wins` — flag overrides project default; source
   `"cli"`.
3. `test_explain_global_fallback` — unknown project; all sources
   `"global"`; submit_warning surfaces.
4. `test_explain_output_format` — `job submit --project project-c --explain`
   stdout contains expected header and source annotations.

### Integration test

`tests/integration/test_project_defaults_live.py`:

1. `test_project_defaults_applied_to_db_row` — fixture YAML with
   `project-c.defaults.max_wall_s=9999` and `host_pin="laptop"`; submit;
   query DB row; assert resolved values present.

Mark with `@pytest.mark.live` and guard with `RUN_LIVE_JOBD=1`.

---

## 8. Effort estimate and dependencies

### Effort estimate

| Task                                                                   | Hours         |
| ---------------------------------------------------------------------- | ------------- |
| `ProjectEntry`/`ProjectDefaults` dataclasses + `load_projects` rewrite | 1.5           |
| Update `_persist_projects` for round-trip safety                       | 0.5           |
| Update `resolve_priority` signature for new type                       | 0.5           |
| Project defaults resolution block in `app.py:submit`                   | 1.0           |
| `JobSubmit.preemptible` sentinel change + CLI update                   | 0.5           |
| `POST /resolve` endpoint + `ResolvedConfig` model                      | 1.5           |
| `--explain` flag in CLI + output formatter                             | 1.0           |
| Update `projects_list` endpoint to include defaults                    | 0.5           |
| Missing-project warning wiring                                         | 0.5           |
| Tests (all four test files)                                            | 3.0           |
| Update `conftest.py` fixture `sample_projects_yaml`                    | 0.25          |
| **Total**                                                              | **~11 hours** |

### Prerequisites — must change atomically in same commit

1. **`load_projects` return type change** from `dict[str, int]` to
   `dict[str, ProjectEntry]`. Six call sites must be updated atomically:
   `config.py:resolve_priority` (line 72), `app.py:list_projects`
   (505-507), `app.py:set_project_priority` (509-513),
   `app.py:nudge_project_priority` (515-522), `app.py:_persist_projects`
   (648-653), `conftest.py:sample_projects_yaml`.

2. **`JobSubmit.preemptible` sentinel change**: `bool` → `bool | None =
None`. CLI must omit the field when the flag is not explicitly passed.

3. **No new DB columns needed.** All target fields exist in `jobs` already.

4. **No new `JobSubmit` fields needed.** Only the `preemptible` type change.

5. **Matcher requires no changes.** Project defaults are resolved at submit
   time and stored on the Job row; the matcher operates on the resolved
   row.

### API contract changes

- `JobSubmit.preemptible: bool → bool | None`. Backward-compatible: explicit
  `true`/`false` continues to work; omitting the field now falls through to
  project defaults instead of hardcoded `false`.
- New endpoint `POST /resolve` returning `ResolvedConfig`. Additive only.
- `POST /submit` response unchanged: resolved values appear in the existing
  `JobInfo` fields; some previously-`null` fields may now be non-null.

---

## 9. Out of scope (explicit non-goals)

- Config management framework: no env-var interpolation, no secrets, no
  per-host overrides, no inheritance chains. One flat YAML file.
- ARC routing logic: `escalate_to_arc` is parsed and stored. The matcher
  does not act on it. Separate BACKLOG item.
- Auto-preempt opt-in defaults: `preemptible: true` here means "jobs in
  this project are preemptible by default." The auto-preempt-by-default
  policy migration is a separate BACKLOG item (#8 in work order).
- `job projects set-default` CLI mutation commands: hand-edit + `job
reload` is sufficient.
- Per-host project override: no per-machine copy.
- Priority duplication: `priority` under `defaults` is accepted in the
  schema (symmetry with other fields) but the loader uses the top-level
  `priority` key as the canonical source; `defaults.priority` only applies
  when the top-level is absent.

---

## 10. `roots:` — cwd-derived project identity

### The problem

`--project` is free text, chosen once at submit time and typed by a human
every time after. A directory does not change; the spelling a person types
for it does — `stage2_sweep`, `leaf-assay`, `gamma-rerun` are
typical one-off run labels for jobs that in every other sense belong to
one long-running project. Each of those typos-that-aren't-typos used to fall
through to `_default`, at `_default`'s priority, with no warning anyone
reads twice. `roots:` gives a project a second way to be recognized: not by
what the caller typed, but by where the job runs from.

### Schema

```yaml
projects:
  beta:
    priority: 78
    roots:
      - /home/user/beta
```

`roots` is a top-level key, a sibling of `priority` and `defaults:`, not
nested inside `defaults:`. It lives outside `defaults:` on purpose: a
`defaults:` key an old broker doesn't recognize is silently dropped (that is
the right behavior for a forward-compatible per-job default), but a dropped
root doesn't degrade the feature, it removes it — invisibly, on exactly the
machine whose config is wrong. `roots:` is validated hard instead: each
entry must be an absolute path (a relative path raises), a string (not an
int, list, etc.), and never `/` — a root of `/` would contain every cwd on
the fleet, silently handing this one project every otherwise-unidentified
job, `/tmp` scratch runs included. Any of those raises at `load_projects`
time rather than being dropped, so a bad root is caught at reload/broker
start, not discovered later as a job that quietly ran at the wrong
priority. See `tests/test_projects_yaml.py::test_a_bad_root_is_a_load_error_not_a_silent_drop`.

Trailing slashes are normalized away (`/home/user/beta/` and
`/home/user/beta` are the same root) but nothing else about a root
is inferred — see path matching, below.

### Resolution order — three rules

The identity for a job's `project` field is decided by trying three rules in
order, and stopping at the first that produces an answer:

1. **The typed name, registered.** `req.project` is folded (case and `-`/`_`
   insensitive — see `canonical_project_name`) and checked against the
   registered projects. If that fold lands on a registered project, that
   project's priority and defaults apply, in full, and **`cwd` is never
   consulted.** This is the safety property the other two rules sit behind:
   a directory's `roots:` can never override an operator's explicit,
   correctly-spelled project selection. Rule 2 only ever gets a turn when
   rule 1 comes up empty — a job typed `--project beta` from inside
   `/home/user/gamma` is `beta`, full stop, not a fight between
   the typed name and the cwd.
2. **The cwd, rooted.** Only when rule 1 found nothing: every registered
   project's `roots` are checked against `cwd`. The **deepest matching root
   wins** (most path components), so a project nested inside a parent's tree
   keeps its own identity rather than inheriting the parent's. If the
   deepest root is claimed by more than one project, that is treated as
   unresolvable, not as a coin flip — see "Ambiguous roots," below.
3. **Neither.** Falls through to `_default`; if `_default` is registered,
   `resolve_effective_config` also sets `unknown_project_warning` naming the
   typed project. Nothing new here — this is the same fallback that existed
   before `roots:`.

`_default` itself is never eligible for a root under rule 2 — it is the
fallback priority row, not a project, and letting it carry roots would
silently convert every unmatched job into a confidently-identified one
(`project_from_cwd` skips it explicitly).

### Path matching: component-wise, not textual, and not symlink-aware

A root matches when `cwd` equals the root or lives under it, compared
**path-component by path-component** — not `cwd.startswith(root)`. That
distinction is the whole reason this is safe to ship: a bare string prefix
would say `/home/user/beta2` is inside `/home/user/beta`,
handing a sibling directory (and whatever unrelated project lives there) its
neighbour's priority. Comparing tuples of path parts (`_path_is_within` in
`src/jobd/config.py`) makes the boundary structural: `/home/user/beta2`
and `/home/user/beta/../beta2` do not match a root of
`/home/user/beta`; `/home/user/beta/sweeps` does.

`..` is collapsed before the comparison, lexically (`os.path.normpath`), so
the components compared are the ones the path actually denotes. Without that
step the example above went the other way: `/home/user/beta/../beta2`
led with a literal `beta` component and matched, and worse,
`/home/user/beta/../../tmp` — a job really running in `/tmp` — priced
at beta's `78`. `cwd` is free text on the wire (`--cwd` is a CLI flag,
`JobSubmit.cwd` a plain string), so that was reachable from a caller, not
just in theory. A root containing a `..` component is rejected at load
instead: a root is an identity boundary, so write the directory it denotes.

Beyond `..`, matching stays purely lexical on the components of `cwd` as
submitted — it does **not** resolve symlinks or bind mounts, and cannot: the
broker matches paths that live on a worker's filesystem, which it has no
access to, so a `realpath` would answer for the wrong disk. Collapsing `..`
needs no filesystem; resolving a symlink does. The two agree except where a
`..` steps back through a symlinked ancestor — `/a/link/../b` denotes `/a/b`
lexically but something else on disk — and that one case is matched as
written, deliberately: the only alternative reads a filesystem the broker
cannot see. So a symlink that points into a rooted directory from somewhere
else does not inherit that root's identity, and a root pointed at a symlink
target will not match a job submitted against the symlink path. If a
project's real working directory is reached through a symlink in practice,
root the symlink path itself (or every path a caller might plausibly type),
not just the target.

### Ambiguous roots

Two projects can, by editing error, both list a root that contains the same
`cwd` — e.g. one project's root is an ancestor directory of another's, and a
job runs from a path both consider theirs at equal depth. When that happens
`project_from_cwd` does not guess: it returns no identity at all (the job
falls through to rule 3, `_default`) and logs a warning naming both
projects and the contested `cwd`. This mirrors exactly what
`canonical_project_name` already does when two registered names fold
together — guessing in either case would run someone's jobs at a
neighbour's priority, which is the exact failure this feature exists to
prevent. Fix it by narrowing one project's root to a subdirectory, or by
merging the two projects.

### `--project` is still required

`roots:` changes what a submitted job's identity can come from; it does not
change whether `--project` must be given. `--project` remains a required
flag on every `job submit`. What changes is what its value _means_: when the
typed name resolves via rule 2 instead of rule 1, the typed spelling is kept
as the job's `project_label` (what a human searches for) while `project`
(what gets priced and reported) is the cwd-derived identity.

Where each is visible differs, and the difference matters when you are
debugging:

- `project` and `project_label` are both persisted on the Job row and
  returned in `JobInfo` (`project_label` is `null` when the two agree, so
  the field reads as "something was substituted here"). `job list --project`
  matches **either**, so `job list --project stage2_sweep` still finds
  the job that was priced as `beta` — though the rendered table column
  shows `project`, the scheduling identity.
- `matched_root` — the root that supplied the identity — is **not** on the
  Job row. It is computed at resolution time and surfaced in three
  places: the `POST /resolve` response (so `job submit --explain` prints
  it, alongside the typed label), the `validation.effective_matched_root`
  field of a `job submit --dry-run` plan, and the `cwd_identity_applied`
  event recorded when rule 2 fires. It is `None`/absent whenever cwd was
  not consulted. The roots a broker has loaded are listed by
  `GET /projects` / `job projects list`.

### The roots shipped in this file

Every root currently in `config/projects.yaml` was derived by replaying
`tests/data/project_cwd_corpus.csv` — 3,608 real submits, 249 distinct
`(project, cwd)` pairs — ranked by job volume per `cwd`, and added only
where every project name ever typed from that directory read as a variant
of one project. See the comments beside each project's `roots:` entry for
the specific evidence, and `tests/test_corpus_replay.py` for the same
corpus replayed end-to-end through the real resolver.


## `projects.local.yaml` — private roots and names

`config/projects.yaml` is tracked and public, so its `roots:` are illustrative.
Real filesystem roots (and any project name you would rather not publish) go in
`config/projects.local.yaml`, which is gitignored and read from the same directory
as `projects.yaml`. An entry there replaces the same-named tracked entry wholesale
(priority, defaults, roots) or adds a new project; names it does not mention are
left as tracked, and `_default` changes only if the file declares it.

```yaml
projects:
  alpha:
    priority: 80
    roots: [/home/me/alpha]
```

Deploying: copy the file next to the broker's `projects.yaml` (the Docker image reads
`$JOBD_CONFIG_DIR`, so mount it there). Without it the broker still starts; only
cwd-derived identity (rule 2) is limited to the roots the tracked file ships.