# Project configuration

> Documentation index: [llms.min.txt](https://caseyrfsmith.github.io/scull/llms.min.txt) · Document manifest: [documents.json](https://caseyrfsmith.github.io/scull/documents.json)

Breadcrumbs: Reference > Project configuration

`scull.yaml` is the recommended policy surface for a Scull project. It keeps source metadata
mappings, OpenAPI normalization, and extraction-model rules consistent across local runs and CI.
The file is auto-discovered at the project root; the same resolved configuration is also available
through the Python API.

Scull can run with built-in defaults when the file is absent, which is useful for a first trial.
For a maintained schemaset, commit the file so build decisions are explicit and reviewable.

## Quick Start

### Declarative (scull.yaml)

```yaml
metadata_mappings:
  source_url: [source-of-truth, custom_edit_url, canonical_url]
  last_verified: [last-reviewed, verified_date]
  owner: [owner, maintainer, author]

defaults:
  model_api_base: https://inference.baseten.co/v1

models:
  schemafier:
    allowed:
      - baseten/thinkingmachines/inkling-small
      - baseten/deepseek-ai/DeepSeek-V4-Flash-0731
  eval:
    allowed:
      - baseten/zai-org/GLM-5.3-Flash
      - openai/gpt-4o

oas_normalize:
  rules:
    - match: "(.*)DetailRecord$"
      rename: "\\1.Detail"
```

### Programmatic API

```python
from pathlib import Path
from scull.config import load, ScullConfig

# Auto-discover from cwd (or pass explicit path)
config = load()

# Or load from a specific file
config = load(path=Path("/custom/path/scull.yaml"))

# Access fields
print(config.metadata_mappings["source_url"])
# → ["source-of-truth", "custom_edit_url", "canonical_url"]

print(config.defaults.get("model"))
# → "anthropic/claude-haiku-4-5"

# Check if a model is allowed for an activity
try:
    config.check_model_allowed("schemafier", "baseten/zai-org/GLM-5.3-Flash")
except ValueError as e:
    print(e)
    # Model 'baseten/zai-org/GLM-5.3-Flash' is not in the allowed list
    # for 'schemafier' in scull.yaml (models.schemafier.allowed): ...
```

## API Reference

### `load(path=None, cwd=None) → ScullConfig`

Load `scull.yaml`. Returns built-in defaults if the file doesn't exist — safe to call without a config file.

**Parameters:**

- `path` (Path, optional) — Explicit config file path to load
- `cwd` (Path, optional) — Working directory to search from (default: `Path.cwd()`)

**Returns:** A `ScullConfig` instance with all fields populated (defaults if no file found).

**Search order:**

1. `path` argument if provided
2. `<cwd>/scull.yaml` (default: `./scull.yaml`)

### `class ScullConfig`

Resolved project configuration. All fields have safe defaults.

#### Fields

| Field               | Type                   | Default                  | Purpose                                                                              |
| ------------------- | ---------------------- | ------------------------ | ------------------------------------------------------------------------------------ |
| `metadata_mappings` | `Dict[str, List[str]]` | Built-in signal mappings | Maps frontmatter keys → internal signals (source\_url, last\_verified, etc.)         |
| `defaults`          | `Dict[str, str]`       | `{}`                     | Raw defaults block; supported commands read documented keys such as `model_api_base` |
| `models`            | `Dict[str, Dict]`      | `{}`                     | Activity-scoped model allowlists (`{"schemafier": {"allowed": [...]}}`)              |
| `oas_normalize`     | `Dict[str, Any]`       | `{}`                     | Ordered regex rules for normalizing OAS schema names before field IDs are emitted    |
| `source_path`       | `Optional[Path]`       | `None`                   | The path the config was loaded from (None = using built-in defaults)                 |

#### Methods

##### `allowed_models(activity: str) → Optional[List[str]]`

Get the allowed model list for an activity, or `None` if unrestricted.

```python
# Returns None if no allowlist configured (unrestricted)
allowed = config.allowed_models("unknown_activity")

# Returns list of strings if allowlist exists
allowed = config.allowed_models("schemafier")
# → ["baseten/thinkingmachines/inkling-small", "baseten/deepseek-ai/DeepSeek-V4-Flash-0731"]
```

##### `check_model_allowed(activity: str, model: str) → None`

Raise `ValueError` if `model` isn't in the configured allowlist for `activity`. No-op if no allowlist is configured.

```python
config.check_model_allowed("eval", "openai/gpt-4o")   # OK — in allowlist
config.check_model_allowed("eval", "gpt-5-chaos")     # Raises ValueError
```

Typical usage: call before invoking scull-schemafier or eval agents to enforce policy.

```python
def run_schemafier(model: str):
    config.check_model_allowed("schemafier", model)
    # Now safe to call the LLM extraction pipeline
```

## Signal Mappings

When prose files have YAML frontmatter, signals are extracted by trying each aliased key in order (first match wins). These are stored in the schema's `sources` block for traceability.

### Built-in Signals

The following signals are recognized even without a `scull.yaml`:

| Signal             | Recognized Aliases                                                 | Description                              |
| ------------------ | ------------------------------------------------------------------ | ---------------------------------------- |
| `source_url`       | `source-of-truth`, `custom_edit_url`, `canonical_url`, `permalink` | Canonical URL for the doc page           |
| `last_verified`    | `last-reviewed`, `verified_date`                                   | When the content was last reviewed       |
| `verified_against` | `verified-against`, `api_version`, `version`                       | Version/API this doc covers              |
| `review_interval`  | `review-interval`, `review_cadence`                                | How often to re-verify (e.g., "monthly") |
| `owner`            | `owner`, `maintainer`, `author`                                    | Responsible team/person                  |
| `related`          | `related`, `see-also`, `references`, `cross-references`            | Links to related concepts/docs           |

### Customizing Mappings

Project-declared mappings **fully override** built-in mappings per signal (not merged). For example, to replace the `source_url` aliases entirely:

```yaml
metadata_mappings:
  source_url: [my-url-key, alternate-key]
```

This replaces all four built-in aliases with just two custom ones. To add to existing aliases instead of replacing them, keep all original keys plus any new ones.

## Defaults Block

Any key-value pair in the `defaults` block is exposed through `config.defaults.get(key)`. A key is
not automatically a CLI default merely because it appears in this mapping. The extraction command
currently consumes this documented key:

| Key              | Usage                | Example                           |
| ---------------- | -------------------- | --------------------------------- |
| `model_api_base` | Default API base URL | `https://inference.baseten.co/v1` |

It can be overridden by `--model-api-base` or `SCULL_MODEL_API_BASE`. The extraction model itself
comes from `--model` or `SCULL_MODEL`; schema and index locations come from their command options
and query-layer path discovery.

## OpenAPI name normalization

`oas_normalize.rules` applies ordered regular-expression renames before OpenAPI schema names become
field IDs and `structure` keys:

```yaml
oas_normalize:
  rules:
    - match: "(.*)DetailRecord$"
      rename: "\\1.Detail"
```

Each name uses the first matching rule. With no rules, source schema names pass through unchanged.

## Model Allowlists

Define which models are permitted for each activity. Use in CI to prevent accidental usage of expensive or experimental models.

```yaml
models:
  schemafier:    # LLM extraction pipeline
    allowed:
      - baseten/thinkingmachines/inkling-small    # example extraction model
      - baseten/deepseek-ai/DeepSeek-V4-Flash-0731  # fallback
    fallback_chain:
      - baseten/deepseek-ai/DeepSeek-V4-Flash-0731
  eval:          # Eval agent runner
    allowed:
      - anthropic/claude-haiku-4-5                # cheap eval default
      - baseten/zai-org/GLM-5.3-Flash             # fast non-thinking
  benchmark:     # Multi-model comparison runs
    allowed:
      - baseten/zai-org/GLM-5.3-Flash
      - anthropic/claude-haiku-4-5
      - baseten/deepseek-ai/DeepSeek-V4-Flash-0731
```

Activities not listed here have no restrictions. The scull-schemafier validates every model in the effective
primary plus fallback chain before making an extraction call. This means a fallback must also appear
in `models.schemafier.allowed`; an allowed primary does not make an unlisted fallback allowed. Call
`config.check_model_allowed()` in your own model-calling code to enforce the same policy.

Extraction requests use LiteLLM's request-scoped unsupported-parameter handling. This lets
provider-specific models such as configured OpenAI GPT-5 variants omit parameters they reject
(including `temperature=0`) while preserving the supported JSON response format used by extraction.
Supported primary models still receive their supported parameters, and the setting is not applied
globally to the LiteLLM process. Fallback model IDs remain the exact strings in `fallback_chain`.
If an endpoint still explicitly rejects temperature, extraction retries once without that parameter;
unrelated errors continue through the existing fallback chain. The initial prompt explicitly requests
JSON, and candidate validation and the four response-format attempts remain in place.

```python
import sys
from scull.config import load

config = load()

model = sys.argv[1]  # e.g., "gpt-5-ultra-expensive"
config.check_model_allowed("benchmark", model)  # Fails before calling LLM
```
