> built 2026-09-27 11:23 UTC from 49602f1 (main) · nw 0.0.62. Details: build_info.json

# index.html.md

<!-- generated by epythet -->

# nw

**Narrative Workflow** — the substrate audiovisual production apps are built on.

A project is a folder. A **genre** — music video, explainer, commentary weave,
slideshow — is the reusable specialization on top: pure data, declared over the
substrate, carrying no engine of its own. `nw` owns the engine: the typed
project facade, the `prepare → plan → execute` split with a cost gate, the
Transform contract, an async job layer, a provenance graph with freshness
queries, and QA reports. Apps (`reelee`, `muvid`, `braidio`) supply their own
body schemas, Transforms, and genres — without modifying `nw`.

```python
import nw

# Declare a kind of production (apps do this once, in their own package).
nw.register_genre(
    nw.Genre(
        slug="slideshow",
        title="Slideshow",
        description="Stills over narration, assembled to a video.",
        transform_names=("clips_to_animatic.ffmpeg",),
        projection_entrypoint="clips_to_animatic.ffmpeg",
        templates=(nw.Template(slug="lecture", title="Lecture deck"),),
    )
)

nw.list_genres()  # ['slideshow']
nw.genre_catalog()  # JSON-able: what a CLI / HTTP route / MCP tool serves
nw.resolve_genre(
    "slideshow", "lecture"
)  # {'genre': ..., 'template': ..., 'params': {...}}
```

## Install

```bash
pip install nw
```

## Genre and Template — the central abstraction

A `Genre` is a **reusable definition of a production kind**. It is a frozen
dataclass that *references* substrate pieces by name rather than owning them:

| field                   | what it declares                                                                    |
|-------------------------|-------------------------------------------------------------------------------------|
| `body_schema_uris`      | the lacing body schemas (`annot://schema/<kind>/vN`) its artifacts validate against |
| `transform_names`       | the `nw.transforms` entries forming its pipeline DAG                                |
| `strategy_names`        | the optional `nw.renderers` strategies it dispatches to                             |
| `projection_entrypoint` | the final assemble/render step producing the delivered artifact                     |
| `templates`             | named presets *within* the genre (see below)                                        |
| `intake_kinds`          | the “what are you making?” answers that select this genre                           |
| `cost_profile`          | a short tag routing the cost gate to the right estimator                            |
| `defaults`              | the “start from scratch” params                                                     |
| `status`                | `available` · `experimental` · `planned`                                            |

A **`Template`** is a named preset *within* a genre — a filled-in default
configuration (“Deep Dive”, “Children’s book”, “Math explainer”). The substrate
owns a Template’s *identity* (slug / title / description) and carries a
genre-defined `params` payload it deliberately does **not** interpret; the app
that owns the genre validates and resolves those params. That keeps a genre
self-describing for any consumer (a CLI, an HTTP catalog, an MCP connector)
while app-specific meaning stays in the app.

`nw` ships **no** built-in genres. Concrete genres register themselves from
their own packages, so adding one is a one-file registration — the same
open-closed shape as `nw.transforms` and `nw.renderers`:

```python
nw.register_genre(nw.Genre(slug="music-video", title="Music video", ...))
nw.get_genre("music-video").is_ready()  # every declared transform/strategy present?
nw.recommend_genre("essay")  # an intake answer -> a genre slug (or None)
```

### Genre → project: resolve, initialize, create

Three registries connect a chosen genre to an actual project. Each is optional,
and each is registered by the genre’s **owning app** — so a host that aggregates
many genres can serve any of them without knowing which app owns which.

```python
# 1. resolve — pure: (genre, template) -> the creation envelope
nw.resolve_genre("music-video", "cinematic_clip")
# {'genre': 'music-video', 'template': 'cinematic_clip', 'params': {...}}

# 2. initialize — the side-effecting twin: seed a freshly-created project
proj = nw.Project.init("my_video")
nw.initialize_genre("music-video", proj, template="cinematic_clip")

# 3. create — for a *plugged-in* genre a host aggregates but doesn't own:
#    the owning app supplies "make a project for this in the caller's own space"
nw.create_genre_project("commentary-weave", caller_id, "ep_01")

# ...and when the host will SERVE the project, it says where it goes:
nw.create_genre_project(
    "commentary-weave", caller_id, "ep_01", projects_dir=my_projects_dir
)
```

An initializer must confine its side effects to the project it is given, so a
failed create can be reverted by removing the project folder —
`create_genre_project` rolls back automatically and is all-or-nothing.

**A genre project factory places a project where its caller asks; it does not own
the location.** `projects_dir` is the directory the project folder is created *in*
(the new project’s root is `projects_dir/<project_id>`), so a host that has to
*serve* a guest genre’s project can put it where its own resolver and lister look —
without which the project is a sibling of nothing the host can address. `None` (the
default) leaves placement to the genre’s app, so every pre-existing caller is
unchanged. Ask `nw.can_place_genre_project(slug)` first: a factory written before
this argument existed is **refused** rather than quietly satisfied somewhere else.
One that accepts the argument and ignores it is caught by an *outcome* check on the
created root — acceptance is not the guarantee, the outcome is — and an outcome nw
cannot verify (a factory that accepts a placement and returns no project) is a
failure rather than a pass. The rollback that follows is **bounded by the
placement**: nothing outside it is deleted, because that branch is precisely the one
where nw has concluded it does not know what the factory did.

The naming rationale (Genre / Template over `kind` / `format` / `recipe` / …) is
in [thorwhalen/nw#10](https://github.com/thorwhalen/nw/issues/10) and the
architecture discussion that settled it.

## Transforms — the A → B arrow

Every step in an audiovisual workflow is a `Transform`: screenplay → treatment,
beat → storyboard panel, panel → image, clips → animatic, shot → rendered clip.
A Transform is a **swappable, costed function from A-annotations to
B-annotations**, in two phases:

1. **`plan()`** — *pure data*. Returns a `falaw.Plan` plus *skeleton* output
   annotations that already carry provenance, so even a dry-run inspection shows
   what will be produced, from what, and at what cost. No billable calls.
2. **`execute()`** — runs the Plan, completes the skeletons with real artifact
   references, writes them to the project graph, and returns a `TransformResult`
   with *actual* cost and cache savings.

Transforms are keyed by name in an `xdol.Registry`, following
`<from_kind>_to_<to_kind>[.<flavor>[.<variant>]]`:

```python
nw.register_transform(MyTransform())
t = nw.get_transform("beat_to_panel.llm.default")
plan, skeleton = t.plan(proj, inputs, params=params)
result = t.execute(proj, plan, skeleton)
result.cost_usd_actual, result.cache_hit_savings_usd
```

Most Transforms subclass `nw.BaseTransform` and override only `plan()` plus the
class-level `name` / `input_kinds` / `output_kind` / `params_model` /
`is_batch`. `params_model` is a Pydantic model, which is what gives an MCP
server or a CLI a JSON Schema for the Transform for free.

### A blocked or failed output still tells you why, after reload

`execute(..., on_failure="isolate")` runs what can be run and reports the
rest instead of raising: `result.failed` / `result.blocked` are
`FailedOutput`s carrying a `reason` (and, for a blocked one, `blocked_by`).
That reason is also persisted — `proj.graph.unproduced_outputs()` reads it
back after a reload, and it disappears on its own the moment a retry
produces the real output.

```python
result = t.execute(proj, plan, skeleton, on_failure="isolate")
[f.reason for f in result.failed]  # in this response
[u.body.reason for u in proj.graph.unproduced_outputs()]  # survives reload
```

### A caller’s key reaches `execute` and nothing else

A server rendering on a caller’s bring-your-own credential hands it to
`execute(..., secrets=)` — a read-only `{provider_name: key}` mapping
(`nw.Secrets`) — and to nothing else. It never enters the Plan, the skeleton,
provenance, a cache key, a run record, the job index or a log line; the type
redacts its `repr`, refuses pickling and is not JSON-serializable, so the
accident raises instead of leaking. `fan_out_execute` and `nw.jobs.enqueue`
pass it accepts-it-or-not, exactly like `on_failure`, and bind it around the
call regardless — so a `"fal"` secret is the fal credential even for an
`execute` override that predates the seam. `BaseTransform.execute` (and nw’s
own shot renderers) bind it the same way; an app declares the keyword on its
own paid Transforms and reads the provider it calls (braidio reads
`"elevenlabs"`). A failure message that quotes the key is redacted before it
reaches a run record or the job index.

```python
result = t.execute(proj, plan, skeleton, secrets={"fal": caller_fal_key})
nw.fan_out_execute(t, proj, fan_out, secrets={"elevenlabs": caller_key})
nw.jobs.enqueue(proj, "weave", params, dispatch=..., secrets=secrets)  # not in params
```

## prepare → plan → execute, with a budget gate

The **shot** render unit makes the same split concrete, and it is why cost is
knowable before the network goes near a credit card. It is shot-typed by
construction (`ShotSpec` in, `output.mp4` out), so it is **not** the extension
point for a new render *kind* — register a `Transform` for that. A new way to
render a *shot* still belongs here, and is adapted into a Transform for free
(see “Render strategies” below). Details:
`misc/docs/Rendering Provenance and Partial Re-render.md`.

1. **prepare** (`nw.prepare_shot`) — local work: audio slice, anchor resolution,
   storyboard prompt assembly. No billable calls.
2. **plan** (`nw.plan_render_shot`) — pure data: a `falaw.Plan`. Inspect
   `plan.total_cost_usd` before executing.
3. **execute** (`nw.execute_render`) — the only phase that talks to fal.
   Materializes `shots/<id>/output.mp4` and records a render-decision in the graph.

```python
prep = nw.prepare_shot(proj, "shot_01", upload=False)  # dry-run / cost preview
plan = nw.plan_render_shot(prep, quality="balanced")
print(plan.total_cost_usd, [c.tool for c in plan.calls])

prep = nw.prepare_shot(proj, "shot_01")  # upload=True for the real run
output = nw.execute_render(prep, plan, project=proj)
```

Plans built with `upload=False` are refused at execute time — they exist for
inspection only.

### A stored quote is not a current price

`plan.total_cost_usd` is true at the moment it is read and an *as-of* figure ever after: falaw’s rate tables move — 0.0.46 re-quoted every premium LLM call tenfold upward — so a figure nw persisted before a table moves under-quotes the run it is later used to describe or gate. Under-quoting is the one direction a spend decision must never err in.

`nw.pricing` is the one place nw re-quotes. Give it a plan (or the calls stored in a render decision) and it answers with today’s price, the stale one beside it, and which of the two you are allowed to show:

```python
quote = nw.current_quote(plan)  # or nw.quote_render_decision(payload)
quote.total_usd  # today's price, or None
quote.status  # "unchanged" | "changed" | "unknown"
quote.as_of_total_usd  # what the plan said when it was written
quote.delta_usd  # the movement, or None if either side is unknown
```

`None` means **unknown, never free**. A call carrying no `falaw.CostBasis` — one hand-built outside a `plan_*`, or planned before falaw 0.0.49 — cannot be re-quoted at all, so it comes back unknown rather than repeating its frozen number. `nw.jobs.estimate` and `nw.jobs.enqueue` follow the same rule: when `params["plan"]` is supplied they price *it*, and a caller-supplied `estimated_usd` is ignored. Repricing is descriptive only — `cost_basis` never enters `plan_hash`, so a job’s idempotency key and falaw’s per-call cache key are byte-identical to what they were before, and a resumed render still dedups onto work already paid for.

Money already **spent** (`project.total_spend_usd()`) is deliberately *not* re-quoted: a receipt is not a quote.

## A typed project on disk

`nw.Project` is a small facade over a project folder. The folder is the single
source of truth: `project.json` holds project-level metadata; a per-project
lacing graph (`project.annot.sqlite`) holds sections, shots, character /
environment refs, and decisions.

```python
proj = nw.Project.init("my_video", song="track.mp3")
proj.add_character("alex", description="warm, deadpan")
proj.set_character_anchor("alex", "characters/alex/refs/headshot.png")
proj.upsert_shot(
    nw.ShotSpec(
        id="shot_01",
        start_s=0.0,
        end_s=8.0,
        characters=("alex",),
        render_strategy="lipsync",
    )
)

proj.read_summary()  # typed ProjectSummary: title, counts, lifecycle stages
proj.read_spec()  # typed ProjectSpec
proj.log_decision("retry_shot", shot_id="shot_03", reason="lipsync drift")

# "Where did we leave off?" — decision tail, what the last *authored*
# change reaches downstream, recorded spend, unrendered shots, deterministic
# next actions. Offline.
brief = proj.resumption_brief()
brief.suggested_next
brief.caveats  # what the numbers above do NOT know — rendered next to them
proj.total_spend_usd()
```

```default
my_video/
  project.json                  # project-level metadata (title, song, style)
  project.annot.sqlite          # lacing graph: sections, shots, refs, decisions
  storyboard.annot.sqlite       # storyboard panels (created on save_storyboard)
  song/                         # master audio
  lyrics/                       # lyrics + alignment (alignment.annot)
  characters/<name>/
    card.json                   # card with reference_image_path (the "anchor")
    refs/                       # candidate images
    selected/                   # curator-picked images
  environments/<name>/
    establishing.png            # the environment anchor
  shots/<shot_id>/
    audio.wav                   # the song over [start_s, end_s]
    shot.json                   # mirror of the shot spec
    output.mp4                  # the rendered shot
  output/
    final.mp4                   # composed timeline
  .nw/
    decisions.jsonl             # tail-grep-able decision audit
    migrated_to_graph           # migration sentinel
```

Pre-graph projects (and muvid fixtures) auto-migrate on first open; the
migration is idempotent and writes a sentinel under `.nw/`.

## Provenance and freshness

All sections, shots, refs, and decisions live in a lacing annotation graph with
`was_derived_from` edges, so “what’s downstream of this change?” is a query, not
a heuristic:

```python
downstream = nw.descendants_of(proj.root, character_annotation_id)
stale = nw.stale_after(proj.root, character_annotation_id)
upstream = nw.derived_from(proj.root, render_annotation_id)
shots = nw.annotations_at_tier(proj.root, "shot")
```

**`descendants_of` and `stale_after` are different questions.** The first is
reachability — *what is downstream of this?* The second is freshness — *what
did this change actually invalidate?* — and it **cuts off early**: every
derived annotation records the content digests of its inputs at write time (a
*verifying trace*), so a change that leaves a value untouched stops
propagating there instead of invalidating the whole subtree. Edit a character
and revert it, or regenerate a panel to identical content, and the stale set
goes back to empty without anything downstream being recomputed.

```python
for v in nw.stale_verdicts(proj.root, character_annotation_id):
    print(v.annotation.id, v.is_stale, v.reason)  # e.g. "upstream-changed"
```

There is also the snapshot form — *what is stale in this project right now?*,
no `changed_id` needed — which is what a freshness indicator wants:

```python
stale_now = nw.all_stale(proj.root)  # every currently-stale annotation
verdicts = nw.stale_verdicts_all(proj.root)  # ... with reasons
```

The rule is asymmetric on purpose: anything unverifiable — no trace, a deleted
input, a trace that no longer covers the current parents, an annotation whose
own `generated_at_time` is lacing’s tick-0 UNKNOWN sentinel (`generated-at-unknown`,
lacing#44) — counts as **stale**. Over-reporting costs a recompute; under-reporting
serves a stale artifact. The unknown-stamp case is on the row’s *own* stamp only:
regenerating the row clears it. A tick-0 *parent* is not a verdict — freshness is
digest-verified, and a legacy root’s stamp is cleared only by the timestamp
backfill, which nothing downstream waits on. For
the scoped walk that also means **no migration**: annotations written before
traces existed behave exactly as they did under pure reachability. The snapshot
form is stricter: on a pre-trace project it reports every derived annotation
stale until each is rewritten through the trace-writing path.

Scope: the **annotation** tier. Artifact-to-artifact lineage is still
unrepresentable upstream ([lacing#14](https://github.com/thorwhalen/lacing/issues/14)).
`nw.stale_after` compares upstream *values*, not the producing Transform, so
bumping a Transform’s implementation does not move a digest.

## Async jobs

`nw.jobs` is a project-scoped facade over [`au`](https://pypi.org/project/au)
for render work too long to sit inside an HTTP request. A *job* is one long,
cancellable unit of work with a durable id, a persistent terminal state, live
progress and a learned ETA, a cost, and a cancel entry keyed by that id:

```python
gate = nw.jobs.estimate(proj, "panel.animate", params)  # cost gate, no enqueue
if not gate["requires_approval"]:
    job = nw.jobs.enqueue(proj, "panel.animate", params)
    nw.jobs.get_job(proj, job.job_id)
    nw.jobs.cancel_job(proj, job.job_id)
nw.jobs.list_jobs(proj)
nw.jobs.to_dict(job)  # the JSON a task tray renders
```

Unknown cost always requires approval. Resubmitting while a job with the same
idempotency key is live returns the existing job rather than launching a
duplicate. Every tunable is keyword-configurable via `nw.jobs.JobsConfig`.
A caller’s credential goes in `secrets=` (held in memory, offered to the
dispatch callable only when it declares the keyword), never in `params`,
which is what the job index persists.

## Render strategies

Each shot carries an open-string `render_strategy`. `nw.renderers` ships five
built-in strategies and lets apps register their own:

| name                | what it does                                                    |
|---------------------|-----------------------------------------------------------------|
| `lipsync`           | character anchor + audio → talking video (omnihuman)            |
| `image_to_video`    | env / fresh storyboard still → animated clip                    |
| `text_to_video`     | prompt-only short clip                                          |
| `still`             | image looped over audio (no video gen)                          |
| `composite_lipsync` | character + environment + audio → composite, then talking video |
```python
nw.list_strategies()  # ['composite_lipsync', 'image_to_video', ...]
nw.register_strategy("my_strategy", MyStrategy())
```

Each built-in strategy is also adapted into the Transform world as
`shot_to_render_result.fal.<strategy>`, so the two registries stay one pipeline
rather than two.

## Storyboard layer

`nw.storyboard` bridges [`artful`](https://pypi.org/project/artful) storyboards
into an `nw.Project` — one panel per shot, seed-image generation planned as a
`falaw.Plan`, then executed:

```python
sb, intervals = nw.storyboard_from_shots(proj)
plan, panel_ids = nw.plan_render_panel_images(sb, quality="balanced")
sb = nw.execute_render_panel_images(proj, sb, plan, panel_ids)
nw.save_storyboard(proj, sb, panel_intervals=intervals)
```

## QA reports

`nw.inspect` answers what a successful render can’t: “did it come out the right
length?”, “is there a frozen-frame segment?”, “are there gaps between shots?”

```python
report = nw.shot_report(proj, "shot_01")
report.duration_within_tolerance  # False if the model returned a short clip
report.has_long_freeze  # True if a ≥1s frozen segment is detected

compose = nw.compose_report(proj)
compose.freeze_alerts  # tuple of suspicious shots
compose.gaps  # gaps between consecutive shots
```

## Validation — a pluggable menu, placed where you want it

`nw.inspect` above answers one question about one shot. `nw.validation` is the
general form: a **registry of checks** that anyone can add to, with dependencies
resolved, independent checks run concurrently, and one report at the end.

```python
report = nw.validate(film, checks=["media.encode_complete", "media.no_long_freeze"])
report.ok  # False if anything failed *or if any check could not run*
print(report.summary())
report.raise_if_failed()  # a hard gate before a publish
```

Three checks ship with nw (`nw.menu()`), each of which has caught a real defect
in a finished film: a missing video or audio stream, an encode that stopped
early (which duration alone cannot show — the container takes its duration from
the *audio* stream), and a long frozen segment.

A check declares what it `requires`, what it `cost`s, whether it is
`parallel_safe`, which binaries it needs — and `example_requests`, the phrases a
person actually says when they want it, which is what lets `nw.suggest("the picture freezes")` turn a request into a selection instead of exposing a
forty-item enum to a model.

```python
@nw.register_check(
    name="type.captions_complete",
    summary="no caption is cut",
    requires=("media.streams_present",),
    cost="dear",
    example_requests=("is the text cut off", "the caption is truncated"),
)
def _captions_complete(film, ctx): ...
```

**Nothing calls `validate` for you**, and that is the decision rather than an
omission: where validation belongs — before a human sees a result, before a
publish, or both — is a judgement about cost and consequence that only the
caller can make. `validate(x)` with no selection runs nothing.

Two honesty rules worth knowing before you gate on it: a check that was
*skipped* (missing binary) or that *raised* makes `report.ok` false — could-not-run
is never a pass — and an unknown check name raises rather than being dropped.

Checks live in whichever package owns the knowledge they apply (type checks next
to `tituli`, motion next to `burns`), and register themselves at import.
Ideas for new ones accumulate as `validation-idea` issues on this repo. The full
record is `misc/docs/Validation — the seam, the menu, and where ideas go.md`.

## Sibling experiments

Comparing four interpretations of the same song is a first-class operation, not
a shell loop:

```python
nw.clone_project(
    "the_bells",
    "the_bells_v1_lipsync",
    preserve=("song", "lyrics", "characters"),
    reset=("script", "shots", "output", ".nw"),
)

summaries = nw.summarize_all(["the_bells_v1", "the_bells_v2", "the_bells_v3"])
nw.apply_to_projects(roots, lambda p: nw.compose_report(p), parallel=True)
```

## API at a glance

```python
# Genres — the reusable production specialization
(
    nw.Genre,
    nw.Template,
    nw.genres,
    nw.register_genre,
    nw.get_genre,
    nw.list_genres,
)
(
    nw.genre_catalog,
    nw.describe_genre,
    nw.recommend_genre,
    nw.resolve_defaults,
    nw.GENRE_STATUSES,
)
(
    nw.GenreResolver,
    nw.genre_resolvers,
    nw.register_genre_resolver,
    nw.resolve_genre,
)
(
    nw.GenreInitializer,
    nw.genre_initializers,
    nw.register_genre_initializer,
    nw.initialize_genre,
)
(
    nw.GenreProjectFactory,
    nw.genre_project_factories,
    nw.register_genre_project_factory,
    nw.has_genre_project_factory,
    nw.can_place_genre_project,
    nw.create_genre_project,
    nw.PLACEMENT_ARG,
)

# Transforms — the A -> B arrow
(
    nw.Transform,
    nw.BaseTransform,
    nw.TransformInputs,
    nw.TransformResult,
)
nw.transforms, nw.register_transform, nw.get_transform, nw.list_transforms

# Folder facade
nw.Project, nw.Project.init, nw.CharacterImage

# Schema
(
    nw.ProjectSpec,
    nw.ProjectSummary,
    nw.ResumptionBrief,
    nw.DecisionEntry,
    nw.SectionSpec,
    nw.ShotSpec,
)
nw.CharacterRef, nw.EnvironmentRef, nw.SongInfo, nw.SCHEMA_VERSION

# Render workflow
nw.prepare_shot, nw.plan_render_shot, nw.execute_render, nw.ShotPreparation

# Async jobs
nw.jobs.estimate, nw.jobs.enqueue, nw.jobs.list_jobs
nw.jobs.get_job, nw.jobs.cancel_job, nw.jobs.to_dict, nw.jobs.JobsConfig

# Strategies
(
    nw.Strategy,
    nw.get_strategy,
    nw.list_strategies,
)
nw.register_strategy, nw.strategies

# Storyboard
(
    nw.open_storyboard,
    nw.save_storyboard,
    nw.storyboard_from_shots,
)
(
    nw.plan_render_panel_images,
    nw.execute_render_panel_images,
)
nw.storyboard_db_path, nw.project_asset_id

# Inspect / QA
(
    nw.shot_report,
    nw.compose_report,
    nw.ShotReport,
    nw.ComposeReport,
)
nw.FrozenSegment, nw.Gap

# Validation — the pluggable menu
(
    nw.validate,
    nw.menu,
    nw.suggest,
    nw.plan_checks,
    nw.register_check,
    nw.checks,
)
nw.Check, nw.Finding, nw.CheckResult, nw.ValidationReport, nw.ValidationError

# Graph / provenance
(
    nw.ProjectGraph,
    nw.derived_from,
    nw.descendants_of,
    nw.stale_after,
    nw.stale_verdicts,
    nw.stale_verdicts_all,
    nw.all_stale,
    nw.FreshnessVerdict,
)
nw.annotations_at_tier, nw.iter_all_annotations, nw.open_project_stores
nw.ProjectGraph.unproduced_outputs  # why a blocked/failed output was never produced

# Experiments
nw.clone_project, nw.apply_to_projects, nw.summarize_all

# Migration
nw.migrate_to_graph, nw.is_migrated
```

## Design notes

- **SSOT on the folder.** Every typed value comes from `project.json` plus the
  project graph. Tools never have to invent their own storage.
- **Genres are declarations, not engines.** `Project`, the prepare → plan →
  execute split, freshness, `nw.jobs`, and the cost gate are all genre-agnostic
  and serve every genre unchanged.
- **Open registries throughout.** Genres, Transforms, and render strategies are
  all `xdol.Registry` entries keyed by string, so apps extend `nw` without
  modifying it. `on_conflict="error"` keeps one plugin from silently shadowing
  another’s.
- **Plan-then-execute.** Cost is computed and inspectable before anything bills.
- **Provenance by default.** Every render and every curator decision is written
  to the graph with `was_derived_from`, so freshness analysis is a graph walk,
  not a heuristic.

Longer rationale lives in `misc/docs/` — *Rendering Provenance and Partial
Re-render* and *Execution Semantics and Fan-out*.

## Known limits

Places where the substrate currently promises less than it looks like it does:

- **Early cutoff stops at the annotation tier.** `stale_after` compares
  annotation *value* digests; artifact→artifact lineage is unrepresentable
  upstream, because `lacing.Provenance.was_derived_from` is `list[UUID]` and an
  `asset_id` is 64 hex chars
  ([lacing#14](https://github.com/thorwhalen/lacing/issues/14)). It also does
  not notice a changed **Transform** — a re-implemented or re-prompted
  Transform moves no upstream digest — and reads a hand-edited output as fresh,
  because relative to its inputs it is.
- **`resumption_brief` still reports reachability, deliberately.**
  `downstream_of_last_authored_change` is `descendants_of`, an explicit upper
  bound named for what it measures; `nw.stale_after` is the narrower answer if
  you want it ([#7](https://github.com/thorwhalen/nw/issues/7)).
- **A removed annotation leaves its verifying trace behind.** Orphan traces are
  inert — they are indexed by a target id that no longer resolves — but nothing
  collects them yet ([#36](https://github.com/thorwhalen/nw/issues/36)).
- **`BaseTransform.execute` has no failure isolation** — one failing call in a
  fan-out Plan raises, and no annotations reach the graph for the calls that did
  succeed ([#25](https://github.com/thorwhalen/nw/issues/25)).

## Dependencies

`pydantic`, [`falaw`](https://pypi.org/project/falaw) (fal-AI planner),
[`lacing`](https://pypi.org/project/lacing) (annotation graph),
[`xdol`](https://pypi.org/project/xdol) (registry),
[`artful`](https://pypi.org/project/artful) (storyboard),
[`au`](https://pypi.org/project/au) (async job substrate),
[`dol`](https://pypi.org/project/dol) (storage).

Optional system tools: `ffmpeg` / `ffprobe` for audio slicing and QA reports.

## License

MIT — see [LICENSE]().

<p class="epythet-aggregates">This documentation as a single file: <a href="nw.md">nw.md</a> (Markdown, for agents).</p>


# _autosummary/nw.bodies.character_ref.html.md

# nw.bodies.character_ref

Body schema for character refs — pointers to a character folder.

URI: `annot://schema/character-ref/v1`

A character-ref is the project-level *pointer* at a character folder
(`characters/<name>/`). The folder holds the canonical card.json,
reference images, voice samples. The annotation’s body carries the
*identity-stable* description of the character — the facts that have to be
re-asserted in every prompt that depicts them (costume, palette,
distinguishing features) — while anything bulkier lives in the folder.

Why a body schema rather than just a project.json field: with this
annotation, reelee can answer “what’s downstream of this character”
across the whole graph without parsing project.json — the character-ref
annotation is the parent node in the provenance graph.

**One vocabulary across the ecosystem.** The stable-attribute fields below
are named to match `artful.schema.ModelSheet`, which already models the
same concepts for a *rendered* model sheet. A character-ref is the
textual/authorial side and a model sheet is the rendered side of the same
character, so `palette_anchors`, `distinguishing_features` and
`do_not_do` are deliberately identical in name *and* type. The one
concept that does **not** overlap is costume: `ModelSheet.costume_set`
maps a costume label to a *render-result annotation id*, whereas a
character-ref needs the costume as prose a prompt builder can inject —
hence `CharacterRefBodyV1.costume` (a `str`), not `costume_set`.
That is a difference of kind, not a naming drift.

### Classes

| [`CharacterRefBodyV1`](_autosummary/nw.bodies.character_ref.html.md#nw.bodies.character_ref.CharacterRefBodyV1)(\*\*data)   | Body of a character-ref annotation.   |
|---------------------------------------------------------------------------------|---------------------------------------|

### *class* nw.bodies.character_ref.CharacterRefBodyV1(\*\*data)

Bases: `BaseModel`

Body of a character-ref annotation.

Every field beyond `name` is optional with a benign default, so dumps
written by any earlier version of this schema load unchanged — this is
an **additive** enrichment of v1, not a new version, and needs no
lacing migration.

#### model_config *: [ClassVar](https://docs.python.org/3/library/typing.html#typing.ClassVar)[ConfigDict]* *= {'extra': 'forbid', 'frozen': True}*

Configuration for the model, should be a dictionary conforming to [`ConfigDict`][pydantic.config.ConfigDict].


# _autosummary/nw.bodies.decision.html.md

# nw.bodies.decision

Body schema for decision-log entries.

URI: `annot://schema/decision/v1`

A decision is a typed, project-local provenance record: which character
anchor was picked, which model overrode the default, why a shot was
retried. Today nw also writes them to `.nw/decisions.jsonl` for
quick tail-grepping; the canonical form is this annotation.

Decisions are stored as **timeless** annotations (no interval) under a
sentinel zero-duration MediaRef pointing at the project’s asset_id.
Reelee will surface them in inspector / network views as the audit
trail for “why did this come out this way?”.

### Classes

| [`DecisionBodyV1`](_autosummary/nw.bodies.decision.html.md#nw.bodies.decision.DecisionBodyV1)(\*\*data)   | Body of a decision annotation.   |
|-----------------------------------------------------------------------------|----------------------------------|

### *class* nw.bodies.decision.DecisionBodyV1(\*\*data)

Bases: `BaseModel`

Body of a decision annotation.

`kind` names the operation (e.g. `"render_shot"`, `"set_character_anchor"`,
`"clone_project"`). `payload` is a free-form dict so producers don’t
need a schema-versioned table per kind. If a kind earns a richer schema
later, it can graduate into its own body URI without disturbing this one.

#### model_config *: [ClassVar](https://docs.python.org/3/library/typing.html#typing.ClassVar)[ConfigDict]* *= {'extra': 'forbid', 'frozen': True}*

Configuration for the model, should be a dictionary conforming to [`ConfigDict`][pydantic.config.ConfigDict].


# _autosummary/nw.bodies.environment_ref.html.md

# nw.bodies.environment_ref

Body schema for environment refs.

URI: `annot://schema/environment-ref/v1`

Same pattern as character-ref: small project-level pointer at an
`environments/<name>/` folder.

### Classes

| [`EnvironmentRefBodyV1`](_autosummary/nw.bodies.environment_ref.html.md#nw.bodies.environment_ref.EnvironmentRefBodyV1)(\*\*data)   | Body of an environment-ref annotation.   |
|-----------------------------------------------------------------------------------|------------------------------------------|

### *class* nw.bodies.environment_ref.EnvironmentRefBodyV1(\*\*data)

Bases: `BaseModel`

Body of an environment-ref annotation.

#### model_config *: [ClassVar](https://docs.python.org/3/library/typing.html#typing.ClassVar)[ConfigDict]* *= {'extra': 'forbid', 'frozen': True}*

Configuration for the model, should be a dictionary conforming to [`ConfigDict`][pydantic.config.ConfigDict].


# _autosummary/nw.bodies.genre_envelope.html.md

# nw.bodies.genre_envelope

Body schema for the project’s resolved genre envelope.

URI: `annot://schema/genre-envelope/v1`

The persisted form of the creation envelope `nw.genres.resolve_genre()`
returns — `{genre, template, params}` — so “what genre/template/params
created this project?” stays answerable after the create call returns
(nw#32). Before this schema existed the envelope went to the *caller* and
nowhere else: reopen the project tomorrow and nothing in it said what genre
it was, so nothing downstream (planner scoping, genre presets on a later
run, a host aggregating another app’s genre) could be genre-conditioned.

## Why an annotation rather than a `ProjectSpec` field

`project.json` is deliberately round-trip-compatible with muvid’s
`ProjectSpec` for `schema_version=1` (nw#30), and its
`extra="ignore"` means a foreign reader’s load/save cycle would silently
*drop* an unmodelled genre key — failing quietly, the worst failure shape.
The graph is nw’s SSOT direction, carries provenance for free, and the
precedent ([`nw.bodies.decision`](_autosummary/nw.bodies.decision.html.md#module-nw.bodies.decision) — a timeless, project-local, typed
record under a sentinel zero-duration reference) already exists. Same
shape here, singleton per project: stored under the `genre-envelope`
tier, replaced in place on re-initialization.

`params` is the *resolved* payload — the effective values after template
and defaults merged — and stays opaque to the substrate, exactly as in
[`nw.genres.Template`](_autosummary/nw.html.md#nw.Template): the app that owns the genre gives it meaning.

### Classes

| [`GenreEnvelopeBodyV1`](_autosummary/nw.bodies.genre_envelope.html.md#nw.bodies.genre_envelope.GenreEnvelopeBodyV1)(\*\*data)   | Body of the (singleton) genre-envelope annotation.   |
|----------------------------------------------------------------------------------|------------------------------------------------------|

### *class* nw.bodies.genre_envelope.GenreEnvelopeBodyV1(\*\*data)

Bases: `BaseModel`

Body of the (singleton) genre-envelope annotation.

Field-for-field the `nw.genres.resolve_genre()` envelope, so the
persisted record and the creation-time contract can never drift apart.

#### model_config *: [ClassVar](https://docs.python.org/3/library/typing.html#typing.ClassVar)[ConfigDict]* *= {'extra': 'forbid', 'frozen': True}*

Configuration for the model, should be a dictionary conforming to [`ConfigDict`][pydantic.config.ConfigDict].


# _autosummary/nw.bodies.html.md

# nw.bodies

Body schemas for nw’s project-graph annotations.

Importing this package registers the schemas with lacing so any annotation
with a matching `body_schema_uri` validates correctly. The schemas are:

- `annot://schema/section/v1`        — timeline section (verse, scene-1, …)
- `annot://schema/shot/v1`           — renderable visual unit
- `annot://schema/character-ref/v1`  — pointer to a character folder
- `annot://schema/environment-ref/v1` — pointer to an environment folder
- `annot://schema/decision/v1`       — provenance-rich decision log entry
- `annot://schema/render-result/v1`  — output of a render Transform
- `annot://schema/verifying-trace/v1` — upstream value digests, for early cutoff
- `annot://schema/unproduced-output/v1` — why a planned output was never
  produced (nw#44), retired by a later successful retry
- `annot://schema/genre-envelope/v1` — the resolved {genre, template, params}
  the project was created as (singleton per project)

These are deliberately small and project-agnostic. Reelee will be able to
walk the same graph for freshness analysis (“what’s downstream of this
character description?”) without bespoke storage.

### Functions

| [`build_verifying_trace`](_autosummary/nw.bodies.html.md#nw.bodies.build_verifying_trace)(\*, for_annotation_id, ...)   | Build the trace annotation for one derived annotation, or `None`.   |
|------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------|

### Classes

| [`CharacterRefBodyV1`](_autosummary/nw.bodies.html.md#nw.bodies.CharacterRefBodyV1)(\*\*data)     | Body of a character-ref annotation.                 |
|-----------------------------------------------------------------------------------|-----------------------------------------------------|
| [`DecisionBodyV1`](_autosummary/nw.bodies.html.md#nw.bodies.DecisionBodyV1)(\*\*data)         | Body of a decision annotation.                      |
| [`EnvironmentRefBodyV1`](_autosummary/nw.bodies.html.md#nw.bodies.EnvironmentRefBodyV1)(\*\*data)   | Body of an environment-ref annotation.              |
| [`GenreEnvelopeBodyV1`](_autosummary/nw.bodies.html.md#nw.bodies.GenreEnvelopeBodyV1)(\*\*data)    | Body of the (singleton) genre-envelope annotation.  |
| [`RenderResultBodyV1`](_autosummary/nw.bodies.html.md#nw.bodies.RenderResultBodyV1)(\*\*data)     | Body of a render-result annotation.                 |
| [`SectionBodyV1`](_autosummary/nw.bodies.html.md#nw.bodies.SectionBodyV1)(\*\*data)          | Body of a section annotation.                       |
| [`ShotBodyV1`](_autosummary/nw.bodies.html.md#nw.bodies.ShotBodyV1)(\*\*data)             | Body of a shot annotation.                          |
| [`UnproducedOutputBodyV1`](_autosummary/nw.bodies.html.md#nw.bodies.UnproducedOutputBodyV1)(\*\*data) | Body of an unproduced-output record.                |
| [`UpstreamDigestV1`](_autosummary/nw.bodies.html.md#nw.bodies.UpstreamDigestV1)(\*\*data)       | One `(upstream annotation, its value digest)` pair. |
| [`VerifyingTraceBodyV1`](_autosummary/nw.bodies.html.md#nw.bodies.VerifyingTraceBodyV1)(\*\*data)   | Body of a verifying-trace annotation.               |

### *class* nw.bodies.CharacterRefBodyV1(\*\*data)

Bases: `BaseModel`

Body of a character-ref annotation.

Every field beyond `name` is optional with a benign default, so dumps
written by any earlier version of this schema load unchanged — this is
an **additive** enrichment of v1, not a new version, and needs no
lacing migration.

#### model_config *: [ClassVar](https://docs.python.org/3/library/typing.html#typing.ClassVar)[ConfigDict]* *= {'extra': 'forbid', 'frozen': True}*

Configuration for the model, should be a dictionary conforming to [`ConfigDict`][pydantic.config.ConfigDict].

### *class* nw.bodies.DecisionBodyV1(\*\*data)

Bases: `BaseModel`

Body of a decision annotation.

`kind` names the operation (e.g. `"render_shot"`, `"set_character_anchor"`,
`"clone_project"`). `payload` is a free-form dict so producers don’t
need a schema-versioned table per kind. If a kind earns a richer schema
later, it can graduate into its own body URI without disturbing this one.

#### model_config *: [ClassVar](https://docs.python.org/3/library/typing.html#typing.ClassVar)[ConfigDict]* *= {'extra': 'forbid', 'frozen': True}*

Configuration for the model, should be a dictionary conforming to [`ConfigDict`][pydantic.config.ConfigDict].

### *class* nw.bodies.EnvironmentRefBodyV1(\*\*data)

Bases: `BaseModel`

Body of an environment-ref annotation.

#### model_config *: [ClassVar](https://docs.python.org/3/library/typing.html#typing.ClassVar)[ConfigDict]* *= {'extra': 'forbid', 'frozen': True}*

Configuration for the model, should be a dictionary conforming to [`ConfigDict`][pydantic.config.ConfigDict].

### *class* nw.bodies.GenreEnvelopeBodyV1(\*\*data)

Bases: `BaseModel`

Body of the (singleton) genre-envelope annotation.

Field-for-field the `nw.genres.resolve_genre()` envelope, so the
persisted record and the creation-time contract can never drift apart.

#### model_config *: [ClassVar](https://docs.python.org/3/library/typing.html#typing.ClassVar)[ConfigDict]* *= {'extra': 'forbid', 'frozen': True}*

Configuration for the model, should be a dictionary conforming to [`ConfigDict`][pydantic.config.ConfigDict].

### *class* nw.bodies.RenderResultBodyV1(\*\*data)

Bases: `BaseModel`

Body of a render-result annotation.

#### model_config *: [ClassVar](https://docs.python.org/3/library/typing.html#typing.ClassVar)[ConfigDict]* *= {'extra': 'forbid', 'frozen': True}*

Configuration for the model, should be a dictionary conforming to [`ConfigDict`][pydantic.config.ConfigDict].

### *class* nw.bodies.SectionBodyV1(\*\*data)

Bases: `BaseModel`

Body of a section annotation.

#### model_config *: [ClassVar](https://docs.python.org/3/library/typing.html#typing.ClassVar)[ConfigDict]* *= {'extra': 'forbid', 'frozen': True}*

Configuration for the model, should be a dictionary conforming to [`ConfigDict`][pydantic.config.ConfigDict].

### *class* nw.bodies.ShotBodyV1(\*\*data)

Bases: `BaseModel`

Body of a shot annotation.

#### model_config *: [ClassVar](https://docs.python.org/3/library/typing.html#typing.ClassVar)[ConfigDict]* *= {'extra': 'forbid', 'frozen': True}*

Configuration for the model, should be a dictionary conforming to [`ConfigDict`][pydantic.config.ConfigDict].

### *class* nw.bodies.UnproducedOutputBodyV1(\*\*data)

Bases: `BaseModel`

Body of an unproduced-output record.

`status` mirrors `nw.transforms.fanout.UnitStatus`’s two
unproduced cases: `"failed"` (the call itself failed) or `"blocked"`
(an upstream call in the same plan failed first). `upstream` is stored
for the `call_index` fallback identity (see the module docstring); it
is not itself a sufficient key.

#### model_config *: [ClassVar](https://docs.python.org/3/library/typing.html#typing.ClassVar)[ConfigDict]* *= {'extra': 'forbid', 'frozen': True}*

Configuration for the model, should be a dictionary conforming to [`ConfigDict`][pydantic.config.ConfigDict].

### *class* nw.bodies.UpstreamDigestV1(\*\*data)

Bases: `BaseModel`

One `(upstream annotation, its value digest)` pair.

#### model_config *: [ClassVar](https://docs.python.org/3/library/typing.html#typing.ClassVar)[ConfigDict]* *= {'extra': 'forbid', 'frozen': True}*

Configuration for the model, should be a dictionary conforming to [`ConfigDict`][pydantic.config.ConfigDict].

### *class* nw.bodies.VerifyingTraceBodyV1(\*\*data)

Bases: `BaseModel`

Body of a verifying-trace annotation.

`digest_scheme` is recorded rather than assumed: lacing documents that
changing `VALUE_FIELDS` or the canonicalisation is a breaking
cache-invalidation event and bumps the scheme string. A trace written
under an older scheme is not comparable, so [`nw.freshness`](_autosummary/nw.freshness.html.md#module-nw.freshness) treats
the mismatch as *unverifiable* (therefore stale) instead of comparing
digests that mean different things.

#### model_config *: [ClassVar](https://docs.python.org/3/library/typing.html#typing.ClassVar)[ConfigDict]* *= {'extra': 'forbid', 'frozen': True}*

Configuration for the model, should be a dictionary conforming to [`ConfigDict`][pydantic.config.ConfigDict].

### nw.bodies.build_verifying_trace(, for_annotation_id, parent_ids, upstream, asset_id)

Build the trace annotation for one derived annotation, or `None`.

* **Parameters:**
  * **for_annotation_id** ([`UUID`](https://docs.python.org/3/library/uuid.html#uuid.UUID)) – Id of the annotation being described.
  * **parent_ids** ([`Iterable`](https://docs.python.org/3/library/typing.html#typing.Iterable)[[`UUID`](https://docs.python.org/3/library/uuid.html#uuid.UUID) | [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]) – Its `provenance.was_derived_from` — annotation ids
    (`UUID`) and artifact asset ids (64-hex `str`, nw#55).
    Duplicates are collapsed, order preserved.
  * **upstream** ([`Sequence`](https://docs.python.org/3/library/typing.html#typing.Sequence)[`Annotation`]) – The resolved parent annotations. \*\*Must cover every
    annotation id in 

    ```
    ``
    ```

    parent_ids\`\`\*\* — a trace that omits a parent
    would let that parent change unnoticed. Asset ids need no
    resolving: they are recorded as they are.
  * **asset_id** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str)) – The project’s asset id, for the sentinel reference.
* **Return type:**
  [`Optional`](https://docs.python.org/3/library/typing.html#typing.Optional)[`Annotation`]
* **Returns:**
  The trace annotation, or `None` when there is nothing to verify
  (no parents) or the trace would be incomplete (a parent could not be
  resolved, or its value could not be digested). `None` is the safe
  answer in both cases: [`nw.freshness`](_autosummary/nw.freshness.html.md#module-nw.freshness) reads *no trace* as
  *unverifiable*, so the annotation keeps today’s conservative
  reachability behaviour instead of being silently declared fresh.

### Modules

| [`character_ref`](_autosummary/nw.bodies.character_ref.html.md#module-nw.bodies.character_ref)         | Body schema for character refs — pointers to a character folder.     |
|-------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------|
| [`decision`](_autosummary/nw.bodies.decision.html.md#module-nw.bodies.decision)                   | Body schema for decision-log entries.                                |
| [`environment_ref`](_autosummary/nw.bodies.environment_ref.html.md#module-nw.bodies.environment_ref)     | Body schema for environment refs.                                    |
| [`genre_envelope`](_autosummary/nw.bodies.genre_envelope.html.md#module-nw.bodies.genre_envelope)       | Body schema for the project's resolved genre envelope.               |
| [`render_result`](_autosummary/nw.bodies.render_result.html.md#module-nw.bodies.render_result)         | Body schema for render results — the output of a render Transform.   |
| [`section`](_autosummary/nw.bodies.section.html.md#module-nw.bodies.section)                     | Body schema for timeline sections (verse, chorus, scene-1, …).       |
| [`shot`](_autosummary/nw.bodies.shot.html.md#module-nw.bodies.shot)                           | Body schema for shots — the renderable visual unit.                  |
| [`unproduced_output`](_autosummary/nw.bodies.unproduced_output.html.md#module-nw.bodies.unproduced_output) | Body schema for unproduced-output records — nw#44.                   |
| [`verifying_trace`](_autosummary/nw.bodies.verifying_trace.html.md#module-nw.bodies.verifying_trace)     | Body schema for verifying traces — what makes early cutoff possible. |


# _autosummary/nw.bodies.render_result.html.md

# nw.bodies.render_result

Body schema for render results — the output of a render Transform.

URI: `annot://schema/render-result/v1`

A render-result records that a shot was rendered: which strategy ran, where
the output landed, the video `lacing.Artifact` it produced, and the
cost as quoted at plan time (`total_estimated_cost_usd` is an as-of figure,
never a current one — see its field description and [`nw.pricing`](_autosummary/nw.pricing.html.md#module-nw.pricing)). Its
`provenance.was_derived_from` includes the shot annotation’s id, so a
freshness traversal from the shot finds the render.

This is the `output_kind` of the render-strategy Transforms (see
`nw.transforms._adapters.render_strategy`). It is intentionally small —
the heavy data (the actual mp4) is the referenced Artifact, not the body.

### Classes

| [`RenderResultBodyV1`](_autosummary/nw.bodies.render_result.html.md#nw.bodies.render_result.RenderResultBodyV1)(\*\*data)   | Body of a render-result annotation.   |
|---------------------------------------------------------------------------------|---------------------------------------|

### *class* nw.bodies.render_result.RenderResultBodyV1(\*\*data)

Bases: `BaseModel`

Body of a render-result annotation.

#### model_config *: [ClassVar](https://docs.python.org/3/library/typing.html#typing.ClassVar)[ConfigDict]* *= {'extra': 'forbid', 'frozen': True}*

Configuration for the model, should be a dictionary conforming to [`ConfigDict`][pydantic.config.ConfigDict].


# _autosummary/nw.bodies.section.html.md

# nw.bodies.section

Body schema for timeline sections (verse, chorus, scene-1, …).

URI: `annot://schema/section/v1`

A section is a labeled span of a project’s master timeline. Sections are
typically non-overlapping but the schema doesn’t enforce that — apps can
encode their own constraints.

### Classes

| [`SectionBodyV1`](_autosummary/nw.bodies.section.html.md#nw.bodies.section.SectionBodyV1)(\*\*data)   | Body of a section annotation.   |
|----------------------------------------------------------------------------|---------------------------------|

### *class* nw.bodies.section.SectionBodyV1(\*\*data)

Bases: `BaseModel`

Body of a section annotation.

#### model_config *: [ClassVar](https://docs.python.org/3/library/typing.html#typing.ClassVar)[ConfigDict]* *= {'extra': 'forbid', 'frozen': True}*

Configuration for the model, should be a dictionary conforming to [`ConfigDict`][pydantic.config.ConfigDict].


# _autosummary/nw.bodies.shot.html.md

# nw.bodies.shot

Body schema for shots — the renderable visual unit.

URI: `annot://schema/shot/v1`

A shot is a timeline-locked visual unit with a render strategy and
references to the characters / environment in frame. The interval lives
on the annotation’s `lacing.MediaRef` (so it shares an interval
space with sections, lyric alignments, viseme tracks, and storyboard
panels).

The render output (`output.mp4`) is a separate `lacing.Artifact`
whose `provenance.was_derived_from` includes this shot’s annotation id.
That’s what enables reelee’s “what’s downstream of this shot?” queries.

### Classes

| [`ShotBodyV1`](_autosummary/nw.bodies.shot.html.md#nw.bodies.shot.ShotBodyV1)(\*\*data)   | Body of a shot annotation.   |
|-------------------------------------------------------------------------|------------------------------|

### *class* nw.bodies.shot.ShotBodyV1(\*\*data)

Bases: `BaseModel`

Body of a shot annotation.

#### model_config *: [ClassVar](https://docs.python.org/3/library/typing.html#typing.ClassVar)[ConfigDict]* *= {'extra': 'forbid', 'frozen': True}*

Configuration for the model, should be a dictionary conforming to [`ConfigDict`][pydantic.config.ConfigDict].


# _autosummary/nw.bodies.unproduced_output.html.md

# nw.bodies.unproduced_output

Body schema for unproduced-output records — nw#44.

URI: `annot://schema/unproduced-output/v1`

`TransformResult.failed` / `.blocked` (nw#25) carry a reason for a
planned output that was never produced, but only within the response that
produced them — reload the project and the hole is unexplained again. This
schema is the persisted record of that reason, written through
[`nw.graph.ProjectGraph.add_unproduced_output()`](_autosummary/nw.graph.html.md#nw.graph.ProjectGraph.add_unproduced_output) at the same choke point
`execute()` writes successes through
(`RenderStrategyTransform.execute` self-stamps the same way, since it
overrides `execute` and bypasses the base implementation).

## Why a sidecar tier rather than the output kind’s own schema

Reading the live registry, most output kinds already validate as an empty
skeleton — so nothing on the *write* side forces a new tier. The *read* side
decides it: several consumers (e.g. a retry planner) compute “already
produced” from rows carrying the output kind’s `body_schema_uri`. A
tombstone written under that URI would mark the failed unit \*\*already
produced\*\*, and a retry would never be planned again — turning a transient
failure into a permanent one. So this is its own tier, never borrowing the
output kind’s URI, exactly like [`nw.bodies.verifying_trace`](_autosummary/nw.bodies.verifying_trace.html.md#module-nw.bodies.verifying_trace) never
borrows its target’s.

## Identity and lifecycle

A record’s retirement/dedup key is **not** just `(transform_name,
upstream)` — two units of the same fan-out (different `mapping_key`, e.g.
two panels of the same beat) can share an identical upstream set, and
keying on upstream alone let one unit’s success retire an *unrelated* unit’s
still-outstanding record. The real key is:

- `(transform_name, instance_id, call_index)` when `instance_id` is
  known — the fan-out unit’s own
  `nw.transforms.fanout.work_item_instance_id()` (a pure function of
  > `(transform_name, mapping_key)`), threaded from

  `fan_out_execute()` through an `execute()`
  : implementation that accepts the `unit_instance_id` keyword (the same
    accepts-it-or-not seam `_accepts_keyword()`
    already uses for `on_failure`). `call_index` still has to agree even
    here: a unit’s own plan can carry more than one call, and matching on
    `instance_id` alone would collapse two of that unit’s own outputs into
    one key;
- `(transform_name, call_index, upstream)` otherwise — `call_index` is
  this output’s position within its `execute()` call’s `skeleton` tuple,
  which disambiguates multiple outputs of one batch call that share
  identical upstream parents even with no fan-out involved. This fallback
  is still not a full identity: two DIFFERENT units run outside a fan-out
  (no `instance_id` threaded at all) with identical upstream parents and
  the same `call_index` still alias, and one succeeding retires the
  other’s record too — the residual version of the original bug, scoped to
  callers that never pass `unit_instance_id`.

[`add_unproduced_output()`](_autosummary/nw.graph.html.md#nw.graph.ProjectGraph.add_unproduced_output) **dedupes on this key**:
a second record for the same identity replaces the first rather than
accumulating — a unit failing twice must not leave a first-run reason
readable as a live blocker after the unit has since failed differently (or
the record is stale but still there). [`add_annotation()`](_autosummary/nw.graph.html.md#nw.graph.ProjectGraph.add_annotation)
retires (removes) any record matching a real output’s identity the moment
that output is written — a successful retry clears its own record.

\*\*A write that bypasses `add_annotation` — a raw `store.add` — leaves
its matching record in place.\*\* It reads back as a live blocker after
reload even though the output was, in fact, produced. Route every derived
write through [`add_annotation()`](_autosummary/nw.graph.html.md#nw.graph.ProjectGraph.add_annotation), as the module
docstring on [`nw.graph`](_autosummary/nw.graph.html.md#module-nw.graph) already requires for verifying traces.

## Two properties this schema deliberately shares with the verifying trace

1. **Parentless.** `was_derived_from` is empty; the link to what it
   describes runs through the body’s own identity fields instead. Wiring it
   as a provenance edge would put every record into its subject’s
   `descendants_of` set, and — worse — [`nw.freshness`](_autosummary/nw.freshness.html.md#module-nw.freshness) would read
   matching digests as fresh, reporting a hole as verified-fresh.
2. **Excluded from freshness by construction.** Because it is parentless it
   never appears in any `descendants_of` walk, so `stale_verdicts_all` /
   `/api/freshness` are untouched and no lacing migration is owed. It is
   also excluded from [`nw.Project()`](_autosummary/nw.html.md#nw.Project)’s resumption-brief “last authored
   change” the same way (`nw.project._BOOKKEEPING_TIERS`) — it is written
   *after* the run it describes, not authored by the user.

## `reason` never carries raw exception text

The graph is exportable project data, and an exception’s `str()` can carry
a signed URL, a local path, or other operational detail that does not belong
in it. When the `FailedOutput` this record is built from carries an
`error`, [`add_unproduced_output()`](_autosummary/nw.graph.html.md#nw.graph.ProjectGraph.add_unproduced_output) stores a
fixed sentence plus `error_type` here and logs the original reason instead
(`logging.getLogger("nw.graph")`, at `WARNING`). A `reason` with no
`error` (e.g. a `"blocked"` output’s falaw-supplied human string, the
whole point of nw#25 —  *“skipped: no dialogue in this panel”*) is stored
as-is.

### Classes

| [`UnproducedOutputBodyV1`](_autosummary/nw.bodies.unproduced_output.html.md#nw.bodies.unproduced_output.UnproducedOutputBodyV1)(\*\*data)   | Body of an unproduced-output record.   |
|-------------------------------------------------------------------------------------|----------------------------------------|

### *class* nw.bodies.unproduced_output.UnproducedOutputBodyV1(\*\*data)

Bases: `BaseModel`

Body of an unproduced-output record.

`status` mirrors `nw.transforms.fanout.UnitStatus`’s two
unproduced cases: `"failed"` (the call itself failed) or `"blocked"`
(an upstream call in the same plan failed first). `upstream` is stored
for the `call_index` fallback identity (see the module docstring); it
is not itself a sufficient key.

#### model_config *: [ClassVar](https://docs.python.org/3/library/typing.html#typing.ClassVar)[ConfigDict]* *= {'extra': 'forbid', 'frozen': True}*

Configuration for the model, should be a dictionary conforming to [`ConfigDict`][pydantic.config.ConfigDict].


# _autosummary/nw.bodies.verifying_trace.html.md

# nw.bodies.verifying_trace

Body schema for verifying traces — what makes early cutoff possible.

URI: `annot://schema/verifying-trace/v1`

A **verifying trace** records, for one derived annotation, the \*content
digest\* each of its provenance parents had at the moment it was written:

```default
output annotation  X   was_derived_from = [A, B]
verifying trace    T   for_annotation_id = X
                       upstream = [(A, sha256…), (B, sha256…)]
```

`provenance.was_derived_from` alone says only *which* annotations X came
from. That makes freshness a reachability question — “A changed, so
everything reachable from A is suspect” — which is the *Make* cell of the
`Build Systems à la Carte` taxonomy and cannot cut off early. The digests
turn it into a **verifying-trace rebuilder** (Ninja / Shake / Salsa): when
A’s current value digest still equals the one X recorded, X is provably
unaffected and the walk stops there. See [`nw.freshness`](_autosummary/nw.freshness.html.md#module-nw.freshness) for the query
side.

## Why a sidecar annotation rather than a field on `lacing.Provenance`

`lacing.Provenance` is `frozen` / `extra="forbid"` and the
envelope has no migration ladder — `lacing.schema.register_migration`
migrates **bodies**, keyed by `body_schema_uri`, and the persisted store
refuses to open at a different `SCHEMA_VERSION`. Adding a field there is a
real on-disk migration against live project data.

lacing stores `body` as free-form JSON validated by `body_schema_uri`,
and `register_body_schema` is public API nw already calls six times, so a
new *body* type costs nothing. [`nw.bodies.decision`](_autosummary/nw.bodies.decision.html.md#module-nw.bodies.decision) is the precedent —
a timeless, project-local, typed provenance record stored under a sentinel
zero-duration reference. This is the same shape with a typed payload.
(thorwhalen/reelee#253 decision D6.)

## Two properties this schema deliberately has

1. **A trace is not a descendant of what it describes.** Its
   `was_derived_from` is empty and the link runs through the body’s
   `for_annotation_id` instead. Wiring it as a provenance edge would put
   every trace into its target’s `descendants_of` set — i.e. bookkeeping
   would show up in the user-facing freshness answer.
2. **A missing trace means “stale”, never “fresh”.** [`build_verifying_trace()`](_autosummary/nw.bodies.verifying_trace.html.md#nw.bodies.verifying_trace.build_verifying_trace)
   returns `None` rather than a partial record when a parent cannot be
   resolved, and [`nw.freshness`](_autosummary/nw.freshness.html.md#module-nw.freshness) treats an annotation with no usable
   trace exactly as today’s reachability walk does. That is what makes this
   change need no migration: every pre-existing annotation keeps its current
   behaviour.

### Functions

| [`build_verifying_trace`](_autosummary/nw.bodies.verifying_trace.html.md#nw.bodies.verifying_trace.build_verifying_trace)(\*, for_annotation_id, ...)   | Build the trace annotation for one derived annotation, or `None`.   |
|------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------|

### Classes

| [`UpstreamDigestV1`](_autosummary/nw.bodies.verifying_trace.html.md#nw.bodies.verifying_trace.UpstreamDigestV1)(\*\*data)     | One `(upstream annotation, its value digest)` pair.   |
|---------------------------------------------------------------------------------|-------------------------------------------------------|
| [`VerifyingTraceBodyV1`](_autosummary/nw.bodies.verifying_trace.html.md#nw.bodies.verifying_trace.VerifyingTraceBodyV1)(\*\*data) | Body of a verifying-trace annotation.                 |

### *class* nw.bodies.verifying_trace.UpstreamDigestV1(\*\*data)

Bases: `BaseModel`

One `(upstream annotation, its value digest)` pair.

#### model_config *: [ClassVar](https://docs.python.org/3/library/typing.html#typing.ClassVar)[ConfigDict]* *= {'extra': 'forbid', 'frozen': True}*

Configuration for the model, should be a dictionary conforming to [`ConfigDict`][pydantic.config.ConfigDict].

### *class* nw.bodies.verifying_trace.VerifyingTraceBodyV1(\*\*data)

Bases: `BaseModel`

Body of a verifying-trace annotation.

`digest_scheme` is recorded rather than assumed: lacing documents that
changing `VALUE_FIELDS` or the canonicalisation is a breaking
cache-invalidation event and bumps the scheme string. A trace written
under an older scheme is not comparable, so [`nw.freshness`](_autosummary/nw.freshness.html.md#module-nw.freshness) treats
the mismatch as *unverifiable* (therefore stale) instead of comparing
digests that mean different things.

#### model_config *: [ClassVar](https://docs.python.org/3/library/typing.html#typing.ClassVar)[ConfigDict]* *= {'extra': 'forbid', 'frozen': True}*

Configuration for the model, should be a dictionary conforming to [`ConfigDict`][pydantic.config.ConfigDict].

### nw.bodies.verifying_trace.build_verifying_trace(, for_annotation_id, parent_ids, upstream, asset_id)

Build the trace annotation for one derived annotation, or `None`.

* **Parameters:**
  * **for_annotation_id** ([`UUID`](https://docs.python.org/3/library/uuid.html#uuid.UUID)) – Id of the annotation being described.
  * **parent_ids** ([`Iterable`](https://docs.python.org/3/library/typing.html#typing.Iterable)[[`UUID`](https://docs.python.org/3/library/uuid.html#uuid.UUID) | [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]) – Its `provenance.was_derived_from` — annotation ids
    (`UUID`) and artifact asset ids (64-hex `str`, nw#55).
    Duplicates are collapsed, order preserved.
  * **upstream** ([`Sequence`](https://docs.python.org/3/library/typing.html#typing.Sequence)[`Annotation`]) – The resolved parent annotations. \*\*Must cover every
    annotation id in 

    ```
    ``
    ```

    parent_ids\`\`\*\* — a trace that omits a parent
    would let that parent change unnoticed. Asset ids need no
    resolving: they are recorded as they are.
  * **asset_id** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str)) – The project’s asset id, for the sentinel reference.
* **Return type:**
  [`Optional`](https://docs.python.org/3/library/typing.html#typing.Optional)[`Annotation`]
* **Returns:**
  The trace annotation, or `None` when there is nothing to verify
  (no parents) or the trace would be incomplete (a parent could not be
  resolved, or its value could not be digested). `None` is the safe
  answer in both cases: [`nw.freshness`](_autosummary/nw.freshness.html.md#module-nw.freshness) reads *no trace* as
  *unverifiable*, so the annotation keeps today’s conservative
  reachability behaviour instead of being silently declared fresh.


# _autosummary/nw.checks.html.md

# nw.checks

The checks nw itself ships — a small, honest default menu.

These are the three that need nothing nw does not already shell out to, and
they are here because each of them has already caught a real defect in a
finished film:

* **a missing stream** — a render that produced a video track and no audio, or
  an audio track and no video, and returned a path either way;
* **an encode that stopped early** — a ten-minute cut killed by the OOM killer
  two-thirds of the way through, whose container still reported the full
  duration, because the container takes its duration from the *audio* stream.
  Every duration check passed. Counting frames is what catches it;
* **a long freeze** — a model returning a too-short clip and a `tpad`
  fallback holding the last frame for four seconds, which looks exactly like a
  deliberate hold until you measure it.

They are registered at import of [`nw`](_autosummary/nw.html.md#module-nw), so they are on the menu. They are
not *run* by anything: see [`nw.validation`](_autosummary/nw.validation.html.md#module-nw.validation) on why validation is placed by
a caller and never assumed.

Every check here is built on [`nw.inspect`](_autosummary/nw.inspect.html.md#module-nw.inspect), which is the older, direct form
of the same knowledge. That module stays: a caller who wants one typed report
about one shot should keep calling `shot_report`. These wrap it for callers
who want a *selection* of checks scheduled and reported together.

### Functions

| [`as_path`](_autosummary/nw.checks.html.md#nw.checks.as_path)(target)           | The media file a target refers to.   |
|----------------------------------------------------------------------------|--------------------------------------|
| [`register_builtin_checks`](_autosummary/nw.checks.html.md#nw.checks.register_builtin_checks)() | Put nw's own checks on the menu.     |

### nw.checks.as_path(target)

The media file a target refers to.

Checks are handed whatever the caller validates. These built-ins want a
file, so they accept a path, a string, or anything with a `path` or
`output_path` attribute — and say so plainly when they get none, rather
than reporting a clean bill of health on something they never opened.

* **Return type:**
  [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)

### nw.checks.register_builtin_checks()

Put nw’s own checks on the menu. Idempotent.

* **Return type:**
  [`None`](https://docs.python.org/3/builtins/constants.html#None)


# _autosummary/nw.delivery.html.md

# nw.delivery

What a genre hands back when a human wants to *hold* what it made.

Every genre in the federation renders something a person eventually wants to watch,
hear, or send to a client. Getting those bytes from the server to that person is
split along ownership lines, and this module owns the vocabulary in the middle:

- **The genre owns resolution.** Given a caller and an artifact reference, which
  file is it, and is it theirs? Only the genre knows its own workspace layout.
- **The host owns transport.** Signing, expiry, streaming, the watch page. Only
  the host knows its public URL, its secret, and its route.
- **This module owns the noun they exchange** — [`Deliverable`](_autosummary/nw.delivery.html.md#nw.delivery.Deliverable) (and its
  sibling [`ProjectSummary`](_autosummary/nw.delivery.html.md#nw.delivery.ProjectSummary)) — plus the four functions that pin the seam:
  [`Resolver`](_autosummary/nw.delivery.html.md#nw.delivery.Resolver), [`Lister`](_autosummary/nw.delivery.html.md#nw.delivery.Lister), [`ProjectLister`](_autosummary/nw.delivery.html.md#nw.delivery.ProjectLister) and
  [`Organiser`](_autosummary/nw.delivery.html.md#nw.delivery.Organiser). A genre registers the ones it offers
  > (`resolve` is mandatory; see [`check_delivery_source()`](_autosummary/nw.delivery.html.md#nw.delivery.check_delivery_source)), and absence
  > of the rest is a capability declaration, never an error.

nw is where this belongs because it is the one package every genre already
reaches and which reaches none of them: `muvid`, `braidio` and `reelee` all
depend on nw, and nw imports no genre. A type owned by any one of them would make
the other two depend sideways.

## Why this module exists at all

It is the repair for a defect that shipped, and the shape of the defect is the
argument for the module. reelee typed its resolver seam `-> Path`; muvid’s
resolver returned its own `ResolvedArtifact` dataclass carrying the content
type and a human filename. Both were reasonable in isolation, both were tested,
and both test suites were green — because each tested only its own side. In
production the host did `Path(resolved).suffix` on muvid’s dataclass and raised
`TypeError`, so \*\*every music-video download 500’d, and had since the day it
was registered.\*\* Nobody saw it, because a separate gap meant no caller could
obtain a token for that genre in the first place: the failure was unreachable,
so it was invisible, so it persisted while a paying user rendered five videos he
could never retrieve.

Two rules follow, and they are the module’s whole reason to be:

1. **One type, defined once, imported by both sides.** A seam described in two
   places is two seams that agree by luck.
2. **The richer half wins.** muvid returned `content_type` and `filename`
   because the transport genuinely needs both — a bare `Path` forces the host
   to re-derive a media type it was already told, and to name the download after
   an opaque id. [`Deliverable`](_autosummary/nw.delivery.html.md#nw.delivery.Deliverable) keeps them.

## The speakable reference

`Deliverable.ref` is the field a human says out loud. A render id like
`b02fc05417ea` is unusable in conversation — you cannot ask for “a bit less
reverb on b02fc05417ea” — so a genre assigns each deliverable a short label like
`cut 4`, stable for the life of the artifact, and accepts it anywhere the raw
id is accepted. [`parse_ref()`](_autosummary/nw.delivery.html.md#nw.delivery.parse_ref) is the shared parser so every genre reads the
same spellings (`4`, `cut 4`, `cut-4`, `#4`) rather than each inventing
its own near-miss.

The reference is *per project*, not global: it rides alongside a `project_id`
everywhere it is used, which is what keeps it short enough to say.

```pycon
>>> parse_ref("cut 4")
4
>>> parse_ref("cut-12"), parse_ref("#3"), parse_ref("7")
(12, 3, 7)
>>> parse_ref("b02fc05417ea") is None    # a raw id, not an ordinal
True
>>> format_ref(4)
'cut 4'
```

## Authorization is the genre’s job, and it is not optional

A [`Resolver`](_autosummary/nw.delivery.html.md#nw.delivery.Resolver) receives the caller’s email and MUST refuse anything that is
not that caller’s. It raises `KeyError` for “no such artifact” and, where the
distinction is safe to reveal, `PermissionError` for “not yours”. Where the
workspace is already email-scoped the two are indistinguishable and `KeyError`
is the right answer for both — saying which would leak the existence of another
tenant’s work.

A resolver that forgets this turns a signed-URL route into a cross-tenant read
primitive, which is why the Protocol takes `email` as its first argument rather
than letting the host pass it as an afterthought.

### Module Attributes

| [`REF_WORD`](_autosummary/nw.delivery.html.md#nw.delivery.REF_WORD)           | The noun a genre uses when it labels a deliverable for a human ("cut 4").                                                                                         |
|---------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| [`Lister`](_autosummary/nw.delivery.html.md#nw.delivery.Lister)             | `list_deliverables(email, project_id=None) -> list[Deliverable]` — the other half of "give me my work".                                                           |
| [`ProjectLister`](_autosummary/nw.delivery.html.md#nw.delivery.ProjectLister)      | `list_projects(email) -> list[ProjectSummary]` — every project of this caller's in the genre, newest-modified first, INCLUDING projects that have never rendered. |
| [`MAX_TITLE_LEN`](_autosummary/nw.delivery.html.md#nw.delivery.MAX_TITLE_LEN)      | The longest title [`check_title()`](_autosummary/nw.delivery.html.md#nw.delivery.check_title) accepts.                                                                         |
| [`DELIVERY_FUNCTIONS`](_autosummary/nw.delivery.html.md#nw.delivery.DELIVERY_FUNCTIONS) | The four halves a genre may register, in the order a capability report prints.                                                                                    |

### Functions

| [`parse_ref`](_autosummary/nw.delivery.html.md#nw.delivery.parse_ref)(text)                     | The ordinal in a spoken reference, or `None` if it isn't one.           |
|--------------------------------------------------------------------------------------|-------------------------------------------------------------------------|
| [`format_ref`](_autosummary/nw.delivery.html.md#nw.delivery.format_ref)(n)                       | The one spelling we print.                                              |
| [`check_title`](_autosummary/nw.delivery.html.md#nw.delivery.check_title)(title)                  | Validate and normalise a human-assigned title; `ValueError` if refused. |
| [`caller_key`](_autosummary/nw.delivery.html.md#nw.delivery.caller_key)(email)                   | The one normalisation of a caller identity, applied at the seam.        |
| [`safe_message`](_autosummary/nw.delivery.html.md#nw.delivery.safe_message)(exc)                   | An exception's message, reduced to what is safe to show a caller.       |
| [`check_delivery_source`](_autosummary/nw.delivery.html.md#nw.delivery.check_delivery_source)(genre, entry) | Refuse a malformed registration at wiring time, not at first call.      |

### Classes

| [`Deliverable`](_autosummary/nw.delivery.html.md#nw.delivery.Deliverable)(path, content_type, filename[, ...])   | A finished thing a person can watch, hear, or download.                               |
|-----------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------|
| [`Resolver`](_autosummary/nw.delivery.html.md#nw.delivery.Resolver)(\*args, \*\*kwargs)                       | `resolve(email, project_id, artifact_id) -> Deliverable` — a genre's half.            |
| [`ProjectSummary`](_autosummary/nw.delivery.html.md#nw.delivery.ProjectSummary)(project_id[, title, genre, ...])    | A project a caller has — whether or not it has ever rendered.                         |
| [`Organiser`](_autosummary/nw.delivery.html.md#nw.delivery.Organiser)(\*args, \*\*kwargs)                      | `organise(email, project_id, artifact_id, *, title=…, tags=…, note=…) -> Deliverable` |

### nw.delivery.DELIVERY_FUNCTIONS *= ('resolve', 'list', 'list_projects', 'organise')*

The four halves a genre may register, in the order a capability report
prints. `resolve` is mandatory — a genre that cannot resolve a claim
cannot be served at all. The rest are optional, and ABSENCE IS A CAPABILITY
DECLARATION, not a hole: the host answers “this genre does not support
that” (and may report it), never errors on it, and NEVER falls back to a
store of its own.

### *class* nw.delivery.Deliverable(path, content_type, filename, artifact_id='', project_id='', genre='', ref=None, title=None, duration_s=None, size_bytes=None, created_at=None, meta=<factory>)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

A finished thing a person can watch, hear, or download.

`path` is server-side and never leaves the host; it is what the transport
streams. Everything else exists so the host does not have to guess:

- `content_type` — what to serve it as. The genre knows; the host would
  otherwise re-derive it from a suffix.
- `filename` — what it should be called when it lands in someone’s
  Downloads folder. `music_video_test_02-cut-4.mp4` beats `b02fc05417ea`.
- `ref` — the speakable label (see [`format_ref()`](_autosummary/nw.delivery.html.md#nw.delivery.format_ref)).
- `artifact_id` — the stable, unambiguous id. `ref` is the convenience;
  this is the truth, and it is what a signed token is minted against.

The optional descriptive fields are what a listing surface renders, and what
lets a watch page say “10 seconds, 4.4 MB, made yesterday” without opening
the file.

#### *property* kind *: [str](https://docs.python.org/3/builtins/stdtypes.html#str)*

`'video'`, `'audio'`, `'image'` or `'file'` — how to present it.

Derived from `content_type` so a genre never has to declare it twice.

```pycon
>>> Deliverable(Path('a.mp4'), 'video/mp4', 'a.mp4').kind
'video'
>>> Deliverable(Path('a.mp3'), 'audio/mpeg', 'a.mp3').kind
'audio'
>>> Deliverable(Path('a.pdf'), 'application/pdf', 'a.pdf').kind
'file'
```

#### *property* label *: [str](https://docs.python.org/3/builtins/stdtypes.html#str)*

The best short name for a human — the ref if it has one, else the id.

```pycon
>>> Deliverable(Path('a.mp4'), 'video/mp4', 'a.mp4', ref='cut 4').label
'cut 4'
>>> Deliverable(Path('a.mp4'), 'video/mp4', 'a.mp4', artifact_id='b02f').label
'b02f'
```

#### meta *: [dict](https://docs.python.org/3/builtins/stdtypes.html#dict)*

Genre-specific extras a listing or watch page may show. Free-form on
purpose — the host renders what it recognises and ignores the rest, so a
genre can enrich its own surface without a change here.

### nw.delivery.Lister

`list_deliverables(email, project_id=None) -> list[Deliverable]` — the other
half of “give me my work”. Without it a reference is undiscoverable: the user
can only name a deliverable they still remember. `project_id=None` means
every project of that caller’s in this genre.

alias of `Callable`[[…], `list[Deliverable]`]

### nw.delivery.MAX_TITLE_LEN *= 120*

The longest title [`check_title()`](_autosummary/nw.delivery.html.md#nw.delivery.check_title) accepts. Long enough for a real
episode title, short enough to render in one listing row.

### *class* nw.delivery.Organiser(\*args, \*\*kwargs)

Bases: [`Protocol`](https://docs.python.org/3/library/typing.html#typing.Protocol)

`organise(email, project_id, artifact_id, *, title=…, tags=…, note=…) -> Deliverable`

The seam’s fourth function: rename, tag and annotate a DELIVERABLE, owned
by whoever assigns `Deliverable.ref` — never by the host. A
host-owned label store is refused on the record (the asset-surfaces ADR
§3.3): it mints a second naming vocabulary the resolvers cannot resolve,
so the very name a page taught the user would fail in the download tool.
Keeping naming genre-side is what makes names resolvable, because the same
code owns the write and the lookup.

This function arrives ONLY on the authenticated tool path. The signed
download token stays read-only (ADR §3.4) — it is a forwardable bearer
credential, safe precisely because it authorises reading one artifact and
nothing else. Which is why the Protocol takes `email` first and
authorizes exactly as [`Resolver`](_autosummary/nw.delivery.html.md#nw.delivery.Resolver) does, before anything is written.

The durability contract — each guarantee one a real genre can keep:

- **\`\`artifact_id\`\` never changes, and files are never renamed or
  moved.** The id is what a signed token is minted against, and some
  locations are load-bearing (braidio’s episode path is recorded in the
  annotation graph). A flat-set genre whose id is the file stem persists
  naming in a genre-owned sidecar, NOT by renaming the file — a rename is
  also how naming becomes destruction (`os.rename` silently replaces an
  existing target).
- **An accepted title resolves.** After `organise(..., title=T)`
  succeeds, the genre’s own `resolve` accepts `T` for this
  deliverable. Titles pass [`check_title()`](_autosummary/nw.delivery.html.md#nw.delivery.check_title), and a collision with an
  existing name in the genre’s namespace raises `ValueError` naming the
  holder. A genre whose `ref` IS its title mirrors the accepted title
  into `ref` — the label follows the rename; the id still does not.
- **Partial update, all-or-nothing.** `None` means “leave unchanged”;
  `""` clears the title or note, `[]` clears tags (replaced whole,
  never merged — read-modify-write is the caller’s). A field the genre
  cannot persist raises `ValueError` naming it, and nothing is written.
- **The return is a receipt, not an echo**: the Deliverable AS RE-READ
  from storage after the write, so the caller sees exactly what every
  later listing will — a genre that cannot re-read its own write has a
  durability bug this makes visible immediately.
- **\`\`tags\`\` and \`\`note\`\` surface in the returned Deliverable’s \`\`meta\`\`
  under the parameter’s own names** (`meta["tags"]`, `meta["note"]`).
  The spelling is pinned HERE, in the seam, so the write side and every
  listing renderer read one vocabulary.

Raises `KeyError` (host: 404) when nothing resolves, `PermissionError`
(403) where “not yours” is safe to reveal, `ValueError` (400) when a
requested change is refused. Deliberately NO delete: retrievability, cost
and destruction are three separate predicates, and destruction does not
ride a naming function — it would be a separately gated fifth function.

### nw.delivery.ProjectLister

`list_projects(email) -> list[ProjectSummary]` — every project of this
caller’s in the genre, newest-modified first, INCLUDING projects that have
never rendered. The genre is implicit: registration is per-genre, and the
host stamps the registry key onto any row whose `genre` arrives blank.

The error contract, stated once so no genre re-invents it: an empty list is
a POSITIVE CLAIM — “nothing exists under this exact [`caller_key()`](_autosummary/nw.delivery.html.md#nw.delivery.caller_key)” —
and an infrastructure failure must RAISE. The host surfaces a raise as a
per-genre problems entry; it never folds one into an empty result, because
a listing that silently omits a genre can honestly report “you have made
nothing” to a caller with work on disk (the `except: continue` defect).
A lister that degrades its own errors to `[]` rebuilds that defect one
layer down, where the host can no longer see it.

Two boundaries a lister must keep: it MUST NOT create a workspace directory
just to list it — emptiness is the only signal there is, and minting the
directory corrupts it (whether an empty answer means “no work” or “never
seen this caller” is the HOST’s copy to write, with the host’s knowledge of
the allowlist). And rows are the caller’s OWN projects; a genre MAY include
rows shared with the caller if it marks them (`meta["access"]="shared"`).

The keyword `after=` is RESERVED for a future pagination cursor — a genre
must not define it to mean anything else.

alias of `Callable`[[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)], `list[ProjectSummary]`]

### *class* nw.delivery.ProjectSummary(project_id, title='', genre='', created_at=None, modified_at=None, deliverable_count=None, meta=<factory>)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

A project a caller has — whether or not it has ever rendered.

[`Lister`](_autosummary/nw.delivery.html.md#nw.delivery.Lister) enumerates finished work, which means a footage project
with a song and twelve clips and no cut yet is invisible to every surface
in the system (reelee#333). This is the row that makes it findable.

Every existing workspace lister computes an mtime to sort by and then
throws it away — forcing a host that merges several genres’ listings to
re-guess an order it was already told. The richer half wins, so
`modified_at` stays, and `created_at` with it (every manifest already
records it; only the row dropped it).

`deliverable_count` is three-valued on purpose: `None` means “not
counted” (counting may cost a walk the genre chose not to pay), `0`
means counted and genuinely renderless — the project a surface must show
as “no cut yet” rather than omit. `meta` is the same escape valve
[`Deliverable.meta`](_autosummary/nw.delivery.html.md#nw.delivery.Deliverable.meta) is: the host renders what it recognises and
ignores the rest. A genre with internal drawers stamps a disambiguator
there (muvid: `{"muvid_genre": "footage"}`), because one genre
registration may span several workspaces and a `project_id` may appear
in more than one — hosts must not key merged rows by
`(genre, project_id)` alone.

#### *property* label *: [str](https://docs.python.org/3/builtins/stdtypes.html#str)*

The best short name for a human — the title if it has one, else the id.

```pycon
>>> ProjectSummary("we_ll_see", "We'll See").label
"We'll See"
>>> ProjectSummary("we_ll_see").label
'we_ll_see'
```

### nw.delivery.REF_WORD *= 'cut'*

The noun a genre uses when it labels a deliverable for a human (“cut 4”).
One word, so the label stays short enough to say in the middle of a sentence.

### *class* nw.delivery.Resolver(\*args, \*\*kwargs)

Bases: [`Protocol`](https://docs.python.org/3/library/typing.html#typing.Protocol)

`resolve(email, project_id, artifact_id) -> Deliverable` — a genre’s half.

`artifact_id` may be a raw id OR a reference the genre accepts (see
[`parse_ref()`](_autosummary/nw.delivery.html.md#nw.delivery.parse_ref)); resolving both is the genre’s job, because only it knows
the ordering that gives `cut 4` its meaning.

Raises `KeyError` when nothing resolves (the host answers 404) and
`PermissionError` when it resolves but is not the caller’s (403). Never
let a server path escape in the message.

### nw.delivery.caller_key(email)

The one normalisation of a caller identity, applied at the seam.

Bucket keys are lowercased OAuth emails, and workspaces are created
lazily — so a caller who arrives as `Noel@Example.com` after months as
`noel@example.com` would silently mint a second, empty bucket, and their
entire body of work would vanish from every listing with no error. Two
normalisations that almost agree are two buckets; this is the one.

Hosts SHOULD route every seam call through it. It is offered, not
retroactively demanded: existing callers normalise where they already do,
and converge here as they touch those sites.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

```pycon
>>> caller_key("  Noel@Example.COM ")
'noel@example.com'
```

### nw.delivery.check_delivery_source(genre, entry)

Refuse a malformed registration at wiring time, not at first call.

The registration map — `{genre: {"resolve": fn, "list": fn,
"list_projects": fn, "organise": fn}}` — is assembled by hand in the
deployment repo, which makes it the luckiest point of the whole seam: a
key typo’d `"list_project"` does not fail, it silently disables the
capability for that genre, and an unreachable failure is an invisible one
(this module’s founding story). Call this on each entry when building the
map; it returns the entry unchanged so it composes inline.

**This is a pure shape check and must stay one** — no I/O, no imports, no
runtime state — so a deployment repo’s CI can run it over the assembled
map and catch the typo *before* the boot-time raise ever fires. The raise
is the backstop, not the detector; growing a check here that needs a live
store would put the whole-connector blast radius back.

Release-ordering corollary: a new key ships HERE before any genre
registers it, or an older nw on the box refuses a newer genre’s honest
registration.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

```pycon
>>> entry = {"resolve": lambda e, p, a: None}
>>> check_delivery_source("g", entry) is entry
True
>>> check_delivery_source("g", {"list_project": None})
Traceback (most recent call last):
...
ValueError: unknown delivery-source keys for genre 'g': ['list_project'] (known: ['resolve', 'list', 'list_projects', 'organise'])
>>> check_delivery_source("g", {})
Traceback (most recent call last):
...
ValueError: delivery source for genre 'g' has no 'resolve' — a genre that cannot resolve a claim cannot be served
```

### nw.delivery.check_title(title)

Validate and normalise a human-assigned title; `ValueError` if refused.

The shared half of the naming rule, so every genre refuses the same
spellings rather than each inventing its own near-miss:

- **Never ref-shaped.** A title the shared parser reads as an ordinal
  (`"cut 4"`, `"#7"`, `"12"`) is refused everywhere — a resolver
  that tries [`parse_ref()`](_autosummary/nw.delivery.html.md#nw.delivery.parse_ref) first (muvid’s does today; any genre may
  tomorrow) would shadow it forever, so the user would have renamed their
  work into a name that resolves to a *different* artifact.
- **Never path-shaped.** No separators, no `.`/`..`, no control
  characters — a title participates in resolution, so it inherits the
  same hostility to traversal as any other id.

The OTHER half — “does this collide with an existing artifact_id, title
or filename in the genre’s own namespace?” — is the genre’s, because only
the genre knows its namespace. The contract there: a collision raises
`ValueError` naming the current holder, never silently reassigns.

This governs titles assigned through [`Organiser`](_autosummary/nw.delivery.html.md#nw.delivery.Organiser); genre CREATE
paths keep their own (often looser) rules, so a render *created* as
`"12"` may exist that `organise` would refuse to assign — asymmetric,
and deliberate: organise is the door new names arrive through.

```pycon
>>> check_title("  The Slow Open ")
'The Slow Open'
>>> check_title("cut 4")
Traceback (most recent call last):
...
ValueError: 'cut 4' reads as a reference; pick a name that is not 'cut <n>', '#<n>' or a bare number
>>> check_title("a/b")
Traceback (most recent call last):
...
ValueError: a title cannot contain path separators or control characters
```

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

### nw.delivery.format_ref(n)

The one spelling we print. Input is permissive; output never varies.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

```pycon
>>> format_ref(1), format_ref(42)
('cut 1', 'cut 42')
```

### nw.delivery.parse_ref(text)

The ordinal in a spoken reference, or `None` if it isn’t one.

`None` is the signal to fall through to treating the input as a raw
artifact id — which is why this never raises: “not an ordinal” is an
ordinary, expected answer, not an error.

* **Return type:**
  [`int`](https://docs.python.org/3/builtins/functions.html#int) | [`None`](https://docs.python.org/3/builtins/constants.html#None)

```pycon
>>> parse_ref("cut 4"), parse_ref("CUT4"), parse_ref(" cut - 4 ")
(4, 4, 4)
>>> parse_ref("#11"), parse_ref("11")
(11, 11)
>>> parse_ref("b02fc05417ea") is None, parse_ref("") is None
(True, True)
```

Zero and negatives are not references — deliverables are numbered from 1, so
accepting `cut 0` would resolve to a neighbour under a naive index:

```pycon
>>> parse_ref("cut 0") is None
True
```

### nw.delivery.safe_message(exc)

An exception’s message, reduced to what is safe to show a caller.

The seam’s exception vocabulary — `KeyError` / `PermissionError` /
`ValueError` — passes through, because genre authors keep those
messages path-free BY CONTRACT (never let a server path escape in the
message; it is stated on [`Resolver`](_autosummary/nw.delivery.html.md#nw.delivery.Resolver) and it binds every seam
function). Anything else — an `OSError` proudly carrying a server path
— is reduced to its type name: a per-genre problems entry renders in a
tool response a non-developer reads.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

```pycon
>>> safe_message(KeyError("no render named 'x'"))
'KeyError: "no render named \'x\'"'
>>> safe_message(OSError("[Errno 13] /somewhere/private/thing.mp3"))
'OSError'
```


# _autosummary/nw.experiment.html.md

# nw.experiment

Experiment helpers — clone projects, apply operations across siblings.

Replaces the bash glue from the muvid_project run:

- `cp -r the_bells the_bells_v1_lipsync; cp -r the_bells the_bells_v2_…`
  becomes [`clone_project()`](_autosummary/nw.experiment.html.md#nw.experiment.clone_project) calls in a Python loop, with typed
  control over what’s preserved vs. reset.
- `for v in v1 v2 v3 v4; do muvid script-apply …; done` becomes
  [`apply_to_projects()`](_autosummary/nw.experiment.html.md#nw.experiment.apply_to_projects).

The “compare four interpretations” workflow is now a first-class feature
rather than a shell pipeline.

### Functions

| [`apply_to_projects`](_autosummary/nw.experiment.html.md#nw.experiment.apply_to_projects)(roots, fn, \*[, parallel])   | Apply `fn` to each project at `roots` and collect the results.   |
|-------------------------------------------------------------------------------------------------|------------------------------------------------------------------|
| [`clone_project`](_autosummary/nw.experiment.html.md#nw.experiment.clone_project)(src_root, dst_root, \*[, ...])   | Clone an nw project to a new root.                               |
| [`summarize_all`](_autosummary/nw.experiment.html.md#nw.experiment.summarize_all)(roots)                           | Convenience: return a `ProjectSummary` for each project.         |

### nw.experiment.apply_to_projects(roots, fn, , parallel=False)

Apply `fn` to each project at `roots` and collect the results.

* **Parameters:**
  * **roots** ([`Iterable`](https://docs.python.org/3/library/typing.html#typing.Iterable)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str) | [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)]) – Iterable of project roots. Each must point to an existing
    nw project.
  * **fn** ([`Callable`](https://docs.python.org/3/library/typing.html#typing.Callable)[[[`Project`](_autosummary/nw.project.html.md#nw.project.Project)], [`TypeVar`](https://docs.python.org/3/library/typing.html#typing.TypeVar)(`T`)]) – Callable taking a `Project` and returning anything. Use this
    for per-project operations: parsing a script, estimating cost,
    rendering, gathering reports.
  * **parallel** ([`bool`](https://docs.python.org/3/builtins/functions.html#bool)) – When True, run `fn` in a thread pool. Useful when `fn`
    is I/O- or API-bound (e.g. a render). When False (default), runs
    sequentially in submission order — the safest semantics.
* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`TypeVar`](https://docs.python.org/3/library/typing.html#typing.TypeVar)(`T`)]
* **Returns:**
  A list of `fn(project)` results in the same order as `roots`.

### Examples

```pycon
>>> # Estimate cost of all four sibling experiments without rendering:
>>> # totals = apply_to_projects(roots, lambda p: estimate_render_cost(p))
>>> # Apply the same script to all of them after a refactor:
>>> # apply_to_projects(roots, lambda p: parse_script(p))
```

### nw.experiment.clone_project(src_root, dst_root, , preserve=('song', 'lyrics', 'characters'), reset=('script', 'shots', 'output', '.nw'), title=None, force=False)

Clone an nw project to a new root.

* **Parameters:**
  * **src_root** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str) | [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)) – Path to an existing nw project (must contain `project.json`).
  * **dst_root** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str) | [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)) – Destination path. Must not exist (or pass `force=True` to
    overwrite).
  * **preserve** ([`Iterable`](https://docs.python.org/3/library/typing.html#typing.Iterable)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]) – Subtrees of `src_root` to copy verbatim into `dst_root`.
    Default: `("song", "lyrics", "characters")`.
  * **reset** ([`Iterable`](https://docs.python.org/3/library/typing.html#typing.Iterable)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]) – Subtrees of `dst_root` to (re)create as empty after copying.
    Default: `("script", "shots", "output", ".nw")`.
  * **title** ([`Optional`](https://docs.python.org/3/library/typing.html#typing.Optional)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]) – New title for the cloned project. Defaults to `dst_root`’s
    folder name.
  * **force** ([`bool`](https://docs.python.org/3/builtins/functions.html#bool)) – When True, overwrite an existing `dst_root` (refuses by default
    to avoid clobbering work).
* **Return type:**
  [`ProjectSummary`](_autosummary/nw.schema.html.md#nw.schema.ProjectSummary)
* **Returns:**
  `ProjectSummary` of the cloned project.

### nw.experiment.summarize_all(roots)

Convenience: return a `ProjectSummary` for each project.

Equivalent to `apply_to_projects(roots, lambda p: p.read_summary())`.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`ProjectSummary`](_autosummary/nw.schema.html.md#nw.schema.ProjectSummary)]


# _autosummary/nw.freshness.html.md

# nw.freshness

Freshness with **early cutoff** — what is *actually* out of date.

`descendants_of` answers a reachability question: “what is downstream of
this?”. [`stale_after()`](_autosummary/nw.freshness.html.md#nw.freshness.stale_after) answers a freshness question: “what did this
change actually invalidate?”. Those are different questions, and until this
module existed nw answered the second with the first — a one-line alias, so
editing one beat in a 200-shot project reported every descendant stale
whether or not anything about it had changed.

In *Build Systems à la Carte* terms that is the **Make** cell: a dirty-bit
rebuilder, “early cutoff: no”. This module upgrades it to a \*\*verifying
trace\*\* rebuilder (Ninja, Shake, rustc/Salsa) using Salsa’s *backdating*
idea: compare the value you have against the value the consumer recorded,
and stop when they agree. One 32-byte digest comparison replaces loading a
40 MB video, which is what makes cutoff free rather than pointless.

## The rule, stated exactly

The walk has two frontiers over the same rule: [`stale_verdicts()`](_autosummary/nw.freshness.html.md#nw.freshness.stale_verdicts) /
[`stale_after()`](_autosummary/nw.freshness.html.md#nw.freshness.stale_after) classify everything reachable from a `changed_id`;
[`stale_verdicts_all()`](_autosummary/nw.freshness.html.md#nw.freshness.stale_verdicts_all) / [`all_stale()`](_autosummary/nw.freshness.html.md#nw.freshness.all_stale) classify every annotation
with at least one provenance parent — the whole-project snapshot a
freshness indicator wants (parentless annotations are never stale, or an
imported screenplay would read stale forever). An annotation `X` on the
frontier is **stale** when any of these holds, and **fresh** only when none
does:

- `X` itself carries an **unknown** `generated_at_time` — lacing’s tick-0
  `UNKNOWN_GENERATED_AT` sentinel (rows written through the REST path
  before lacing#35 still carry it; lacing#44). A row that cannot be placed
  in time is unverifiable, and it is never read as “the oldest thing in the
  project”. Regenerating `X` writes a fresh stamp, so this clears itself,
- no verifying trace was recorded for `X` ([`nw.bodies.verifying_trace`](_autosummary/nw.bodies.verifying_trace.html.md#module-nw.bodies.verifying_trace)),
- the trace was written under a different digest scheme,
- the trace’s upstream set is not exactly `X.provenance.was_derived_from`,
- a recorded upstream annotation no longer exists,
- a recorded upstream is **itself stale** — its value is about to change,
- a recorded upstream’s *current* value digest differs from the recorded one.

**A tick-0 \*parent\* is not a verdict.** Only `X`’s own stamp is checked.
Freshness has been digest-verified since nw#39: whether `X`’s inputs
changed is answered by comparing the parents’ *current* value digests to
the ones `X` recorded, and a parent’s unknown timestamp says nothing about
that. Staling `X` for a tick-0 parent would also never converge — a
legacy authored root is never regenerated, so no recompute could clear it,
only the timestamp backfill (lacing#46) — and a `regen_all_stale` loop
built on this walk would spend forever on every project with a legacy REST
root. The one place a timestamp *does* decide something is the trace
backfill’s bless walk ([`nw.graph.backfill_traces()`](_autosummary/nw.graph.html.md#nw.graph.backfill_traces)), which now refuses
any row it cannot place against its parents.

Two consequences worth stating, because both are easy to get backwards:

**The comparison lives on the edge, not on the node.** It is tempting to
classify `X` as fresh and then prune the walk there. That is wrong: `X`
having up-to-date *inputs* says nothing about whether `X`’s own *value*
still equals what its children recorded. Rewriting `X` in place makes
`X` fresh and its children stale at the same instant. So every reachable
node is classified against **its own** recorded digests; the walk prunes
nothing.

**Unverifiable means stale.** Every branch above defaults to stale.
Over-reporting wastes a recompute — which the content-addressed `falaw`
cache makes close to free. Under-reporting serves a stale artifact as if it
were current, so every ambiguous case resolves the other way. For the
*scoped* walk this also means no data migration: an annotation written
before traces existed reads as `no-trace` and behaves exactly as it did
under pure reachability. The *snapshot* walk has no such equivalence — on a
pre-trace project it reports every derived annotation stale until each is
rewritten through the trace-writing path; see [`stale_verdicts_all()`](_autosummary/nw.freshness.html.md#nw.freshness.stale_verdicts_all).

## What this does **not** catch

Stated so nobody reads more into the number than is there:

- **A changed Transform.** The trace records upstream *values*, not the
  producing code. Bumping a Transform’s implementation or prompt does not
  move any digest. `stale_after` answers “what did this *annotation* change
  invalidate”, not “what did this *code* change invalidate”.
- **A hand-edited output.** Editing `X`’s body directly leaves its trace
  matching its parents, so `X` reads fresh. That is the intended reading —
  a deliberate override is not stale relative to its inputs — but it does
  mean “fresh” is not “would regenerate identically”.
- **The plan → execute window.** The trace is written when the output is
  *persisted*, so an upstream mutated between planning and writing is
  recorded at its newer value. That needs a concurrent edit during a render.
- **An artifact’s bytes behind its id.** Artifact parents (64-hex asset
  ids in `was_derived_from`, representable since thorwhalen/lacing#14 and
  written by `derive_provenance()` for
  inputs whose body schema declares its asset fields — nw#55,
  `nw.transforms.asset_refs`) are recorded in the trace’s
  > `upstream_assets` and never re-checked: an asset id *is* the SHA-256 of
  > its bytes, so it cannot change, only be replaced — and for a *declared*
  > ref, replacing it changes the body of the annotation that names it, which
  > that annotation’s own digest catches. A ref passed through
  > `derive_provenance(asset_refs=...)` has no naming annotation, so it is
  > trusted as-is: whether that artifact still exists, or has been superseded,
  > is not checked. An artifact parent counts toward “the trace’s upstream set
  > is exactly `was_derived_from`” like any other parent.

### Module Attributes

| [`STALE_REASONS`](_autosummary/nw.freshness.html.md#nw.freshness.STALE_REASONS)   | Every reason that resolves to *stale*.   |
|------------------------------------------------------------------|------------------------------------------|

### Functions

| [`all_stale`](_autosummary/nw.freshness.html.md#nw.freshness.all_stale)(project_root)                  | Every annotation that is currently stale, regardless of cause.        |
|-------------------------------------------------------------------------------------------|-----------------------------------------------------------------------|
| [`stale_after`](_autosummary/nw.freshness.html.md#nw.freshness.stale_after)(project_root, changed_id)    | Return every annotation that `changed_id` actually invalidated.       |
| [`stale_verdicts`](_autosummary/nw.freshness.html.md#nw.freshness.stale_verdicts)(project_root, changed_id) | Classify every annotation downstream of `changed_id`.                 |
| [`stale_verdicts_all`](_autosummary/nw.freshness.html.md#nw.freshness.stale_verdicts_all)(project_root)         | Classify every derived annotation in the project — the snapshot form. |

### Classes

| [`FreshnessVerdict`](_autosummary/nw.freshness.html.md#nw.freshness.FreshnessVerdict)(annotation, is_stale, reason)   | Why one reachable annotation was judged stale (or not).   |
|---------------------------------------------------------------------------------------------------|-----------------------------------------------------------|

### *class* nw.freshness.FreshnessVerdict(annotation, is_stale, reason, upstream_id=None)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

Why one reachable annotation was judged stale (or not).

Emitted by [`stale_verdicts()`](_autosummary/nw.freshness.html.md#nw.freshness.stale_verdicts). `reason` is one of the
`REASON_*` constants; `upstream_id` names the parent that decided it
when a single parent did, so “why is this stale?” has an answer that does
not require re-deriving the walk by hand.

### nw.freshness.STALE_REASONS *: [tuple](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[str](https://docs.python.org/3/builtins/stdtypes.html#str), ...]* *= ('generated-at-unknown', 'no-trace', 'digest-scheme-changed', 'trace-parents-differ', 'trace-unreadable', 'upstream-missing', 'upstream-stale', 'upstream-changed', 'provenance-cycle')*

Every reason that resolves to *stale*. `REASON_FRESH` is the only
verdict that does not, which is the invariant that keeps “unverifiable means
stale” true by construction rather than by review.

### nw.freshness.all_stale(project_root)

Every annotation that is currently stale, regardless of cause.

[`stale_verdicts_all()`](_autosummary/nw.freshness.html.md#nw.freshness.stale_verdicts_all) with the fresh verdicts dropped — the
snapshot counterpart of [`stale_after()`](_autosummary/nw.freshness.html.md#nw.freshness.stale_after), and the primitive a
freshness indicator or a “regenerate everything stale” verb should sit
on instead of re-deriving its own definition of the word.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[`Annotation`]

### nw.freshness.stale_after(project_root, changed_id)

Return every annotation that `changed_id` actually invalidated.

The freshness operation. `changed_id`’s descendants are walked and each
is checked against the upstream value digests it recorded when it was
written ([`nw.bodies.verifying_trace`](_autosummary/nw.bodies.verifying_trace.html.md#module-nw.bodies.verifying_trace)). A descendant whose recorded
inputs still match the current ones is **not** returned — that is the
early cutoff, and it is why this is not `descendants_of` under another
name. The full rule, and the four things it deliberately does not catch,
are in this module’s docstring.

The returned list does NOT include `changed_id` itself (it is the source
of the change, not a stale derivative).

`descendants_of` is unchanged and still answers the reachability
question — “what is downstream of this?” is legitimate and the two verbs
are no longer synonyms. Use [`stale_verdicts()`](_autosummary/nw.freshness.html.md#nw.freshness.stale_verdicts) when you need the
*reason* a given annotation is in (or out of) this set.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[`Annotation`]

### nw.freshness.stale_verdicts(project_root, changed_id)

Classify every annotation downstream of `changed_id`.

The explained form of [`stale_after()`](_autosummary/nw.freshness.html.md#nw.freshness.stale_after): one verdict per reachable
annotation, stale or not, in a deterministic order (generation time, then
id). `changed_id` itself is never included — it is the source of the
change, not a derivative of it.

Use this when the *number* is being questioned. `stale_after` is the
same walk with the fresh verdicts dropped;
[`stale_verdicts_all()`](_autosummary/nw.freshness.html.md#nw.freshness.stale_verdicts_all) is the same classification with no
`changed_id` — the whole-project snapshot.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`FreshnessVerdict`](_autosummary/nw.freshness.html.md#nw.freshness.FreshnessVerdict)]

### nw.freshness.stale_verdicts_all(project_root)

Classify every derived annotation in the project — the snapshot form.

The question a freshness *indicator* asks: “what is stale in this
project right now?”, with no `changed_id` to anchor on. Same
verifying-trace classification as [`stale_verdicts()`](_autosummary/nw.freshness.html.md#nw.freshness.stale_verdicts), over a wider
frontier: every annotation with at least one provenance parent.

Two boundaries that are the point of this living here rather than each
consumer approximating it (nw#39):

- **Parentless annotations are never stale** and stay out of the walk —
  an imported screenplay must not read as stale forever. (nw-written
  verifying traces are parentless, so they stay out too.)
- **Upstream-stale recursion runs over the whole derived set.** The
  scoped walk only recurses into parents inside `reachable` (outside
  it a parent is by construction unaffected by the change); with no
  change there is no such boundary. The cycle guard covers termination.

**Legacy projects read all-stale, by design.** A derived annotation
written before verifying traces existed classifies `no-trace` →
stale, and unlike the scoped walk (which only surfaces it downstream
of an actual change) the snapshot reports it *always*, until it is
rewritten through the trace-writing path. A consumer replacing its own
weaker snapshot with this one is making a behavior change on pre-trace
projects, not installing a pure wrapper.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`FreshnessVerdict`](_autosummary/nw.freshness.html.md#nw.freshness.FreshnessVerdict)]


# _autosummary/nw.genres.html.md

# nw.genres

### nw.genres *= <Registry nw.genres>*

A typed dict-backed plugin registry.

* **Parameters:**
  * **name** – Optional name; appears in error messages and `repr`.
  * **on_conflict** – `'error'` (default) raises `RegistryConflict` when registering
    a key that already exists. `'replace'` silently overwrites.
    `'keep'` silently keeps the original.
  * **(****dict****(****...****)** (*Implements MutableMapping so anything that takes a mapping*)

:param :
:param iteration:
:param length checks:
:param `in` lookups:
:param `.items()`) just works.:


# _autosummary/nw.graph.html.md

# nw.graph

The project annotation graph — read/write helpers + reelee-style traversals.

A nw project’s SSOT for sections, shots, character/environment refs, and
decisions is a per-project lacing `SqliteStore` at
`project.annot.sqlite`. This module wraps that store with typed helpers
so the rest of nw doesn’t need to know about tier names, MediaRef
construction, or annotation envelopes.

Usage from inside the package (illustrative — `project_root`, `shot_body`
and `TimeInterval` are the caller’s fixtures, not defined here, so the
executable lines are skipped rather than exercised):

```pycon
>>> from nw.graph import ProjectGraph
>>> g = ProjectGraph(project_root)
>>> g.upsert_shot_body(shot_body, interval=TimeInterval.from_seconds(0, 8))
>>> for shot in g.shots():
...     ...
```

For reelee’s freshness analysis (planned in §7 of the system overview),
[`derived_from()`](_autosummary/nw.graph.html.md#nw.graph.derived_from) and [`descendants_of()`](_autosummary/nw.graph.html.md#nw.graph.descendants_of) walk the
`provenance.was_derived_from` edges across **all** stores in a project
(project graph + storyboard + alignment). Those are *reachability* queries.
The freshness query that compares content — `nw.stale_after` — lives in
[`nw.freshness`](_autosummary/nw.freshness.html.md#module-nw.freshness); this module is where its input, the verifying trace, is
written (see [`ProjectGraph.add_annotation()`](_autosummary/nw.graph.html.md#nw.graph.ProjectGraph.add_annotation)).

### Functions

| [`all_project_stores`](_autosummary/nw.graph.html.md#nw.graph.all_project_stores)(project_root)                  | Return the **existing** lacing-store file paths under a project.              |
|----------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------|
| [`annotations_at_tier`](_autosummary/nw.graph.html.md#nw.graph.annotations_at_tier)(project_root, tier)           | Return every annotation at the given tier across all of the project's stores. |
| [`backfill_traces`](_autosummary/nw.graph.html.md#nw.graph.backfill_traces)(project_root, \*[, execute])      | Bless a pre-trace project so the verifying-trace rule can read it (nw#58).    |
| [`collect_orphan_traces`](_autosummary/nw.graph.html.md#nw.graph.collect_orphan_traces)(project_root)               | Drop verifying traces whose target annotation no longer exists.               |
| [`derived_from`](_autosummary/nw.graph.html.md#nw.graph.derived_from)(project_root, annotation_id)         | Return the annotations this one was directly derived from.                    |
| [`descendants_of`](_autosummary/nw.graph.html.md#nw.graph.descendants_of)(project_root, ancestor_id)         | Return every annotation whose provenance chain leads back to `ancestor_id`.   |
| [`iter_all_annotations`](_autosummary/nw.graph.html.md#nw.graph.iter_all_annotations)(project_root)                | Walk every annotation in every store under a project (any backend).           |
| [`open_project_stores`](_autosummary/nw.graph.html.md#nw.graph.open_project_stores)(project_root)                 | Yield an iterator of open stores, one per scope, honouring the backend.       |
| [`remove_annotations_with_traces`](_autosummary/nw.graph.html.md#nw.graph.remove_annotations_with_traces)(store, ...[, ...]) | Remove annotations from `store`, plus every trace in it naming them.          |

### Classes

| [`ProjectGraph`](_autosummary/nw.graph.html.md#nw.graph.ProjectGraph)(project_root)                   | Typed read/write facade over the project's lacing graph store.   |
|-----------------------------------------------------------------------------------------------|------------------------------------------------------------------|
| [`StoredCharacterRef`](_autosummary/nw.graph.html.md#nw.graph.StoredCharacterRef)(annotation_id, body)      |                                                                  |
| [`StoredDecision`](_autosummary/nw.graph.html.md#nw.graph.StoredDecision)(annotation_id, body)          |                                                                  |
| [`StoredEnvironmentRef`](_autosummary/nw.graph.html.md#nw.graph.StoredEnvironmentRef)(annotation_id, body)    |                                                                  |
| [`StoredSection`](_autosummary/nw.graph.html.md#nw.graph.StoredSection)(annotation_id, interval, body) |                                                                  |
| [`StoredShot`](_autosummary/nw.graph.html.md#nw.graph.StoredShot)(annotation_id, interval, body)    |                                                                  |
| [`StoredUnproducedOutput`](_autosummary/nw.graph.html.md#nw.graph.StoredUnproducedOutput)(annotation_id, body)  |                                                                  |

### *class* nw.graph.ProjectGraph(project_root)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

Typed read/write facade over the project’s lacing graph store.

Use `Project.graph()` to get one rather than constructing directly.
Each method opens-and-closes the underlying store so concurrent
reads/writes from different processes are safe (SqliteStore is
file-locked).

#### add_annotation(ann, , instance_id=None, call_index=None)

Write one annotation to the project graph, plus its verifying trace.

Registers `ann.tier` if it isn’t a known tier yet — `SqliteStore`
enforces a foreign key on `tier`, so writing under a fresh tier
(e.g. a Transform output kind) would otherwise fail. `add_tier` is
idempotent, so this is a no-op for the built-in project tiers.

This is the single choke point every *derived* annotation in nw and
reelee passes through, which is why the verifying trace
([`nw.bodies.verifying_trace`](_autosummary/nw.bodies.verifying_trace.html.md#module-nw.bodies.verifying_trace)) is recorded here rather than in
`derive_provenance`: that helper returns a `Provenance` and has
no store to write to, and threading one in would change a signature
with production callsites in three repos. Writing at persist time
also covers the paths that build a `Provenance` by hand.

Annotations with no `was_derived_from` parents get no trace — there
is nothing to verify, and they are nobody’s descendant.

It is also where a matching [`add_unproduced_output()`](_autosummary/nw.graph.html.md#nw.graph.ProjectGraph.add_unproduced_output) record is
retired (nw#44): a successful write is proof the thing that record
described has now been produced. `instance_id` / `call_index`
identify *this write’s* unit precisely (see
[`nw.bodies.unproduced_output`](_autosummary/nw.bodies.unproduced_output.html.md#module-nw.bodies.unproduced_output)’s module docstring for the key);
omit `instance_id` only when it is not known — the retirement then
falls back to `(transform_name, call_index, upstream)`, which still
cannot distinguish two DIFFERENT units sharing both an upstream set
and a `call_index` (the residual case the module docstring names),
so it may retire nothing, or (rarely) the wrong record.

\*\*Bypassing this method (a raw `store.add`) leaves a matching
unproduced-output record in place\*\* — it reads as a live blocker
after reload even though this write produced the thing it described.

* **Return type:**
  [`None`](https://docs.python.org/3/builtins/constants.html#None)

#### add_unproduced_output(skeleton, , transform_name, status, reason='', error=None, blocked_by=(), instance_id=None, call_index=None, was_attributed_to=None)

Persist why a planned output was never produced (nw#44).

Mirrors one entry of `TransformResult.failed` / `.blocked`
([`nw.transforms.FailedOutput`](_autosummary/nw.html.md#nw.FailedOutput)) — pass its `skeleton`,
`status`, `reason`, `error` and `blocked_by` straight through.
Written under `nw.bodies.UNPRODUCED_OUTPUT_BODY_SCHEMA_URI`,
never under `skeleton.body_schema_uri` (see the module’s docstring
on why that tier is reserved for what was actually produced).

`instance_id` (a fan-out unit’s
`work_item_instance_id()`) and
`call_index` (this output’s position within its `execute()`
call’s skeleton tuple) together form the identity
[`add_annotation()`](_autosummary/nw.graph.html.md#nw.graph.ProjectGraph.add_annotation) retires by — see
[`nw.bodies.unproduced_output`](_autosummary/nw.bodies.unproduced_output.html.md#module-nw.bodies.unproduced_output)’s module docstring for the full
key. **Dedupes on that identity**: a record already outstanding for
the same key is removed before this one is written, so a unit
failing twice leaves one current record, not two.

Parentless, like a verifying trace.

`reason` never stores raw exception text — see the module
docstring’s “reason never carries raw exception text”. When
`error` is given, the original `reason` is logged
(`logging.getLogger("nw.graph")`, `WARNING`) and the persisted
`reason` becomes a fixed sentence naming `error`’s type.

* **Return type:**
  [`UUID`](https://docs.python.org/3/library/uuid.html#uuid.UUID)

#### append_decision(body, , was_attributed_to='user:nw', was_derived_from=())

Append a decision; never replaces an existing one (the log is append-only).

* **Return type:**
  [`UUID`](https://docs.python.org/3/library/uuid.html#uuid.UUID)

#### genre_envelope()

The recorded genre envelope, or `None` for a genre-less project.

The read half of [`set_genre_envelope()`](_autosummary/nw.graph.html.md#nw.graph.ProjectGraph.set_genre_envelope); consumers should reach
it through [`nw.Project.resolved_genre()`](_autosummary/nw.html.md#nw.Project.resolved_genre), which returns the
plain-dict envelope shape `nw.genres.resolve_genre()` produces.

* **Return type:**
  [`Optional`](https://docs.python.org/3/library/typing.html#typing.Optional)[[`GenreEnvelopeBodyV1`](_autosummary/nw.bodies.genre_envelope.html.md#nw.bodies.genre_envelope.GenreEnvelopeBodyV1)]

#### remove_annotation(annotation_id)

Remove one annotation from the project graph, with its verifying traces.

The delete counterpart of [`add_annotation()`](_autosummary/nw.graph.html.md#nw.graph.ProjectGraph.add_annotation) (nw#36): every trace
whose `for_annotation_id` names `annotation_id` goes with it, so a
deletion never leaves a sidecar behind — an orphaned trace is inert
for freshness but grows the store without bound, and if the id is
later re-used it can even answer a freshness query from digests
recorded for content that is no longer there.

Only the project graph store is touched — the store
[`add_annotation()`](_autosummary/nw.graph.html.md#nw.graph.ProjectGraph.add_annotation) writes to. Annotations living in the other
scopes (storyboard, alignment) are removed by their own facades;
[`collect_orphan_traces()`](_autosummary/nw.graph.html.md#nw.graph.collect_orphan_traces) is the project-wide backstop.

* **Return type:**
  [`bool`](https://docs.python.org/3/builtins/functions.html#bool)
* **Returns:**
  Whether `annotation_id` itself was present (its traces are
  removed either way).

#### set_genre_envelope(body, , was_attributed_to='agent:nw.genres')

Record the resolved `{genre, template, params}` envelope; return its id.

Singleton per project (nw#32): the tier is the identity, so
re-initializing replaces the recorded envelope in place — the
annotation id is stable across replacements, like every entity
upsert. A no-op write (same envelope) writes nothing.

* **Return type:**
  [`UUID`](https://docs.python.org/3/library/uuid.html#uuid.UUID)

#### unproduced_outputs(, transform_name=None)

Every unproduced-output record still outstanding, oldest first.

A record disappears the moment [`add_annotation()`](_autosummary/nw.graph.html.md#nw.graph.ProjectGraph.add_annotation) writes a real
output for the same identity, or another [`add_unproduced_output()`](_autosummary/nw.graph.html.md#nw.graph.ProjectGraph.add_unproduced_output)
call for the same identity supersedes it (dedupe) — what this
returns is exactly “still missing”, survives a reload, and is not a
cache of any in-memory `TransformResult`.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`StoredUnproducedOutput`](_autosummary/nw.graph.html.md#nw.graph.StoredUnproducedOutput)]

#### upsert_character_ref(body, , was_attributed_to='user:nw')

Insert-or-update the character ref with this `name`; return its id.

The id is *stable* across edits — see `_upsert()`.

* **Return type:**
  [`UUID`](https://docs.python.org/3/library/uuid.html#uuid.UUID)

#### upsert_environment_ref(body, , was_attributed_to='user:nw')

Insert-or-update the environment ref with this `name`; return its id.

The id is *stable* across edits — see `_upsert()`.

* **Return type:**
  [`UUID`](https://docs.python.org/3/library/uuid.html#uuid.UUID)

#### upsert_section(body, interval, , was_attributed_to='user:nw')

Insert-or-update the section with this `section_id`; return its id.

The id is *stable* across edits — see `_upsert()`.

* **Return type:**
  [`UUID`](https://docs.python.org/3/library/uuid.html#uuid.UUID)

#### upsert_shot(body, interval, , was_attributed_to='user:nw')

Insert-or-update the shot with this `shot_id`; return its id.

The id is *stable* across edits — see `_upsert()`.

* **Return type:**
  [`UUID`](https://docs.python.org/3/library/uuid.html#uuid.UUID)

### *class* nw.graph.StoredCharacterRef(annotation_id, body)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

### *class* nw.graph.StoredDecision(annotation_id, body)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

### *class* nw.graph.StoredEnvironmentRef(annotation_id, body)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

### *class* nw.graph.StoredSection(annotation_id, interval, body)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

### *class* nw.graph.StoredShot(annotation_id, interval, body)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

### *class* nw.graph.StoredUnproducedOutput(annotation_id, body)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

### nw.graph.all_project_stores(project_root)

Return the **existing** lacing-store file paths under a project.

SQLite-mode only — these are filesystem paths. Code that walks or mutates
project stores should route through [`open_project_stores()`](_autosummary/nw.graph.html.md#nw.graph.open_project_stores) (which
honours the backend seam) rather than opening these paths directly, so it
keeps working when the backend is Postgres.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)]

### nw.graph.annotations_at_tier(project_root, tier)

Return every annotation at the given tier across all of the project’s stores.

Useful for reelee views that lens on a single annotation kind:
`annotations_at_tier(root, "shot")` returns every shot annotation
regardless of which store it lives in (project graph vs. storyboard
vs. alignment).

Asks each store for the tier rather than deserializing every annotation
and filtering. `by_tier` is a real indexed query on all four lacing
backends and was called by nothing in nw; this walked the whole project
to answer a question about one tier. Measured on 2000 annotations with
200 at the tier: **33.5 ms → 3.9 ms**, and the gap widens with project
size because one is O(all rows) and the other O(matching).

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[`Annotation`]

### nw.graph.backfill_traces(project_root, , execute=False)

Bless a pre-trace project so the verifying-trace rule can read it (nw#58).

On a project whose annotations predate nw#24’s trace-writing, the
verifying-trace rule is a behavior change, not a wrapper: every derived
annotation reads stale (`no-trace`), and nothing heals them — regen
skips non-Transform-produced annotations, and body updates write no
trace. This writes, for each derived annotation with no trace, a trace
against its parents’ CURRENT content digests: blessing at-rest state as
fresh, which is exactly the old timestamp rule’s verdict for at-rest
data — semantics-preserving at the moment of migration.

**Report-only by default.** The first thing run against a real user’s
projects should be a read; pass `execute=True` to write. Idempotent
either way: an annotation that already has a usable trace is counted in
`already_traced` and never rewritten, so a partial run is simply
re-run rather than reasoned about. One caveat keeps “a read” honest at
the FILE level: stores are opened with `migrate=True`, so a store
stamped at an older lacing schema is upgraded ON OPEN even under the
default — run this only where the build that serves these stores is
already the new one (the D-vg-mcp-10 deploy ordering; a pre-migrated
file makes an old serving build refuse it).

**Not blessed, by design — the old rule’s own stale verdicts.** A parent
edited AFTER the annotation was derived is exactly the
pending-regeneration state the old timestamp rule reported stale;
blessing it would silently clear a real signal. Such annotations land in
`skipped` and stay no-trace-stale — same verdict, and a later regen
writes the true trace through the chokepoint. (Exact preservation in the
other direction is impossible — the trace rule recurses where the old
rule was one-hop — but that residual over-reports, the direction
[`nw.freshness`](_autosummary/nw.freshness.html.md#module-nw.freshness) documents as the safe one.)

What is deliberately NOT blessed, each with a `skipped` entry naming
the annotation and the reason:

- a parent that no longer exists — that annotation is genuinely
  `upstream-missing`, and a fabricated trace would hide a real hole;
- a parent list carrying artifact refs (64-hex asset ids) — the
  annotation-tier trace cannot cover them (nw#55), and a trace over a
  subset of the parents reads as stale anyway (“upstream set is not
  exactly `was_derived_from`”), so writing one would be decoration;
- a parent whose body cannot be digested — broken data at the producer,
  same rule as `build_verifying_trace()`.

Parentless annotations are never stale by contract, so they are counted
(`parentless`) and need nothing.

Returns one project’s report — callers migrating a tree of projects loop
and get per-project summaries for free:
`{"project", "stores_found", "examined", "backfilled", "already_traced",
"traced_unusable", "parentless",
"skipped": [{"annotation_id", "reason"}, ...], "executed"}`.
`backfilled` is the count of traces written when `execute=True`, and
of traces that WOULD be written otherwise; `executed` says which
reading applies. Read `stores_found` before trusting zeros: a typo’d
or empty root reports all-zero COUNTS, and `stores_found == 0` is what
distinguishes “nothing to migrate” from “not a project here”.
`traced_unusable` counts annotations whose existing trace the
freshness rule cannot use (foreign digest scheme, mismatched upstream
set) — permanently stale, deliberately not overwritten here; expect it
to be zero on genuine pre-trace projects. A broken project (corrupt
store, unreadable `project.json`) RAISES rather than reporting —
catch per root in a tree loop so one damaged project is recorded, not
silently averaged away.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### nw.graph.collect_orphan_traces(project_root)

Drop verifying traces whose target annotation no longer exists.

The backstop for deletion paths that do not (or cannot) go through
[`remove_annotations_with_traces()`](_autosummary/nw.graph.html.md#nw.graph.remove_annotations_with_traces) — a direct `store.remove`, an
external tool, history from before deletions collected traces (nw#36).
Walks every store under the project; a trace is an orphan when its
`for_annotation_id` resolves in **none** of them. Idempotent, and safe
to run as routine maintenance: an orphaned trace is never consulted by
[`nw.freshness`](_autosummary/nw.freshness.html.md#module-nw.freshness), so removing it changes no freshness answer.

A trace whose body cannot be read (not a dict, unparseable target id) is
left in place: it may be an orphan, but deleting what we cannot identify
is worse than carrying it.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`UUID`](https://docs.python.org/3/library/uuid.html#uuid.UUID)]
* **Returns:**
  The ids of the trace annotations removed, in store order.

### nw.graph.derived_from(project_root, annotation_id)

Return the annotations this one was directly derived from.

Walks `provenance.was_derived_from` *one hop only* across all of the
project’s stores.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[`Annotation`]

### nw.graph.descendants_of(project_root, ancestor_id)

Return every annotation whose provenance chain leads back to `ancestor_id`.

Walks `provenance.was_derived_from` *transitively* across all of the
project’s lacing stores. This is the operation reelee’s freshness
analysis is built on (system overview §7): when a node changes, every
annotation in the closure of this set is “downstream of the change.”

Deterministic order: (generation time, id) — the same public ordering
contract as [`nw.freshness.stale_verdicts()`](_autosummary/nw.freshness.html.md#nw.freshness.stale_verdicts). The closure used to be
returned in set-iteration (hash-derived) order, which leaked into every
consumer’s output (nw#39).

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[`Annotation`]

### nw.graph.iter_all_annotations(project_root)

Walk every annotation in every store under a project (any backend).

* **Return type:**
  [`Iterator`](https://docs.python.org/3/library/typing.html#typing.Iterator)[`Annotation`]

### nw.graph.open_project_stores(project_root)

Yield an iterator of open stores, one per scope, honouring the backend.

The backend-aware replacement for `for p in all_project_stores(...):
SqliteStore(p)`. Under SQLite it visits each existing per-scope file;
under Postgres it visits each scope’s tenant in the shared DB. Use it for
both reads (walk `.all()`) and writes (`.remove` / `.add`).

Each store is closed before the next opens, so consume each store’s
annotations before advancing.

* **Return type:**
  [`Iterator`](https://docs.python.org/3/library/typing.html#typing.Iterator)[[`Iterator`](https://docs.python.org/3/library/typing.html#typing.Iterator)[`IntervalAnnotationStore`]]

### nw.graph.remove_annotations_with_traces(store, annotation_ids, , annotations=None)

Remove annotations from `store`, plus every trace in it naming them.

The store-level primitive behind every nw deletion path
([`ProjectGraph.remove_annotation()`](_autosummary/nw.graph.html.md#nw.graph.ProjectGraph.remove_annotation), `write_spec`’s entity
reconciliation, the storyboard wipe). A verifying trace is a sidecar of
the annotation it describes; removing one without the other leaks an
inert row per deletion, forever (nw#36).

* **Parameters:**
  * **store** (`IntervalAnnotationStore`) – An **open** store — the caller owns its lifecycle.
  * **annotation_ids** ([`Iterable`](https://docs.python.org/3/library/typing.html#typing.Iterable)[[`UUID`](https://docs.python.org/3/library/uuid.html#uuid.UUID)]) – Ids to remove. Missing ids are ignored.
  * **annotations** ([`Optional`](https://docs.python.org/3/library/typing.html#typing.Optional)[[`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[`Annotation`]]) – The store’s annotations, if the caller already
    materialized `list(store.all())` — avoids a second scan.
* **Return type:**
  [`set`](https://docs.python.org/3/builtins/stdtypes.html#set)[[`UUID`](https://docs.python.org/3/library/uuid.html#uuid.UUID)]
* **Returns:**
  The subset of `annotation_ids` that was actually present. Trace
  removals are not reported: they are bookkeeping, not content.


# _autosummary/nw.graph_backend.html.md

# nw.graph_backend

Config-driven backend selection for nw’s annotation graph stores.

Phase 4 of the storage migration (reelee#177). A nw project keeps its
annotations in lacing `IntervalAnnotationStore``s — historically one
``SqliteStore` file per *scope* (the project graph, the storyboard, the
lyrics alignment). This module is the **single seam** that decides whether a
given scope is backed by SQLite (the default — byte-for-byte the old
behaviour, one file per scope) or by a shared Postgres database
(`lacing.store.PostgresStore`, tenant-scoped).

## The facade principle in action

Every nw site that needs a graph store — [`nw.migrate.open_project_graph()`](_autosummary/nw.migrate.html.md#nw.migrate.open_project_graph),
the storyboard load/save, the lyrics-alignment read, and the provenance walk in
[`nw.graph`](_autosummary/nw.graph.html.md#module-nw.graph) — routes through [`open_graph_store()`](_autosummary/nw.graph_backend.html.md#nw.graph_backend.open_graph_store) /
[`iter_scope_stores()`](_autosummary/nw.graph_backend.html.md#nw.graph_backend.iter_scope_stores) here. None of them learns *which* backend answered;
[`nw.graph.ProjectGraph`](_autosummary/nw.graph.html.md#nw.graph.ProjectGraph) and every typed accessor are unchanged.

## The environment contract

| Variable            | Meaning                                                                                                                                                   |
|---------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------|
| `NW_GRAPH_BACKEND`  | `sqlite` (**default**) | `postgres`. Unknown /<br/>empty → `sqlite`.                                                                                      |
| `NW_GRAPH_DB_URL`   | (postgres) psycopg conninfo URL for the shared DB.                                                                                                        |
| `NW_GRAPH_OWNER_ID` | (postgres, optional) tenant owner; defaults to<br/>lacing’s `DEFAULT_OWNER_ID`. Forward seam for the<br/>access layer (reelee#174); enforcement deferred. |

**Safety first.** The default (no env, or any unrecognized backend) is always
SQLite — *identical* to the behaviour before this module existed. A local run
never changes and never crashes because Postgres env happens to be unset; if
`NW_GRAPH_BACKEND=postgres` but `NW_GRAPH_DB_URL` is missing, we log a
warning and fall back to SQLite rather than failing.

## Tenant scoping across scopes

In SQLite mode each scope is a distinct file, so they never collide. In
Postgres mode they share tables, so each scope gets a distinct `project_id`
built from the project’s stable `project_asset_id` and the scope name:
`"<asset_id>:<scope>"`. [`iter_scope_stores()`](_autosummary/nw.graph_backend.html.md#nw.graph_backend.iter_scope_stores) enumerates exactly the same
scope set the SQLite walk did, so [`nw.graph.iter_all_annotations()`](_autosummary/nw.graph.html.md#nw.graph.iter_all_annotations) yields
each annotation once under either backend.

### Functions

| [`selected_backend`](_autosummary/nw.graph_backend.html.md#nw.graph_backend.selected_backend)([env])                        | Resolve the configured graph backend from the environment.              |
|-------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------|
| [`open_graph_store`](_autosummary/nw.graph_backend.html.md#nw.graph_backend.open_graph_store)(db_path, \*, asset_id[, ...]) | Open the annotation store for one scope, backend chosen by env.         |
| [`iter_scope_stores`](_autosummary/nw.graph_backend.html.md#nw.graph_backend.iter_scope_stores)(scope_paths, \*, asset_id)   | Yield an iterator of open stores, one per scope, backend chosen by env. |
| [`scope_name_for_db`](_autosummary/nw.graph_backend.html.md#nw.graph_backend.scope_name_for_db)(db_path)                     | Map a legacy per-scope SQLite filename to its scope name.               |

### Classes

| [`GraphBackend`](_autosummary/nw.graph_backend.html.md#nw.graph_backend.GraphBackend)   |    |
|-----------------------------------------------------------------|----|

### nw.graph_backend.GraphBackend

alias of [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

### nw.graph_backend.iter_scope_stores(scope_paths, , asset_id, env=None)

Yield an iterator of open stores, one per scope, backend chosen by env.

The provenance walk in [`nw.graph.iter_all_annotations()`](_autosummary/nw.graph.html.md#nw.graph.iter_all_annotations) needs *every*
store under a project. In SQLite mode that’s “every existing per-scope
file”; in Postgres mode it’s “every scope’s tenant” — and this generator
enumerates exactly the same scope set under both backends, so each
annotation is yielded once either way.

* **Parameters:**
  * **scope_paths** ([`Mapping`](https://docs.python.org/3/library/collections.abc.html#collections.abc.Mapping)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)]) – `{scope_name: legacy_sqlite_path}` — only paths that
    *exist* on disk are visited in SQLite mode; in Postgres mode every
    listed scope is visited (existence is a DB question, not a file
    one).
  * **asset_id** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str)) – The project’s `project_asset_id` (Postgres tenant anchor).
  * **env** ([`Mapping`](https://docs.python.org/3/library/collections.abc.html#collections.abc.Mapping)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)] | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – Environment mapping. Defaults to `os.environ`.
* **Yields:**
  A single iterator that produces each scope’s open store in turn. Each
  store is closed before the next is opened, so callers must consume
  annotations eagerly per store (which the walk does).
* **Return type:**
  [*Iterator*](https://docs.python.org/3/library/typing.html#typing.Iterator)[[*Iterator*](https://docs.python.org/3/library/typing.html#typing.Iterator)[*IntervalAnnotationStore*]]

### nw.graph_backend.open_graph_store(db_path, , asset_id, scope=None, rate=None, env=None)

Open the annotation store for one scope, backend chosen by env.

The single place that decides SQLite-vs-Postgres for a graph store. Callers
pass the legacy SQLite path (still the source of truth for *where* the file
lives in SQLite mode) plus the project’s `asset_id` (the tenant anchor in
Postgres mode).

* **Parameters:**
  * **db_path** ([`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path) | [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)) – The per-scope SQLite path (used directly in SQLite mode; in
    Postgres mode only its filename is used to derive the scope).
  * **asset_id** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str)) – The project’s stable `project_asset_id` — the Postgres
    tenant anchor. Ignored in SQLite mode.
  * **scope** ([`Optional`](https://docs.python.org/3/library/typing.html#typing.Optional)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]) – The logical scope name (`"graph"` / `"storyboard"` /
    `"alignment"` / …). Defaults to deriving it from `db_path`.
  * **rate** ([`Optional`](https://docs.python.org/3/library/typing.html#typing.Optional)[[`int`](https://docs.python.org/3/builtins/functions.html#int)]) – Project-wide rate for the Postgres store. Defaults to lacing’s
    `DEFAULT_RATE`. Ignored in SQLite mode.
  * **env** ([`Mapping`](https://docs.python.org/3/library/collections.abc.html#collections.abc.Mapping)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)] | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – Environment mapping. Defaults to `os.environ`.
* **Return type:**
  `IntervalAnnotationStore`
* **Returns:**
  A live `IntervalAnnotationStore` — `SqliteStore`
  (default) or a tenant-scoped `PostgresStore`. Caller closes it (or
  uses it as a context manager).

### nw.graph_backend.scope_name_for_db(db_path)

Map a legacy per-scope SQLite filename to its scope name.

`project.annot.sqlite` → `"graph"`; `storyboard.annot.sqlite` →
`"storyboard"`; `alignment.annot` → `"alignment"`. Anything else maps
to the file’s stem so a new store kind gets a stable scope automatically.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

### nw.graph_backend.selected_backend(env=None)

Resolve the configured graph backend from the environment.

Pure and side-effect-free. Any unrecognized / empty value resolves to
`"sqlite"` — the safe default that never changes a local run.

* **Parameters:**
  **env** ([`Mapping`](https://docs.python.org/3/library/collections.abc.html#collections.abc.Mapping)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)] | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – Environment mapping to read. Defaults to `os.environ`.
* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)
* **Returns:**
  `"sqlite"` or `"postgres"`.


# _autosummary/nw.html.md

# nw

nw — Narrative Workflow.

Application-orchestration framework for audiovisual projects. A project is
a folder; a **genre** (music video, explainer, podcast clip, slideshow) is a
reusable specialization on top — the first-class successor to what nw
informally called an “app” (see [`nw.genres`](_autosummary/nw.genres.html.md#nw.genres) and issue #10).

Public surface:

- [`Project`](_autosummary/nw.html.md#nw.Project) — folder facade: read/write spec, character anchors,
  shot upserts, decision log, typed summary, session-resumption brief.
- [`ProjectSummary`](_autosummary/nw.html.md#nw.ProjectSummary) — typed read view of a project.
- [`ResumptionBrief`](_autosummary/nw.html.md#nw.ResumptionBrief) — “where we left off”: decision tail, what the
  last *authored* change reaches downstream, recorded spend, deterministic
  next actions. Its `caveats` field carries what those numbers do *not*
  know.
- [`clone_project()`](_autosummary/nw.html.md#nw.clone_project) — replaces `cp -r` for sibling experiments.
- [`apply_to_projects()`](_autosummary/nw.html.md#nw.apply_to_projects) — replaces shell for-loops across roots.
- Schema types: [`ProjectSpec`](_autosummary/nw.html.md#nw.ProjectSpec), [`SectionSpec`](_autosummary/nw.html.md#nw.SectionSpec), [`ShotSpec`](_autosummary/nw.html.md#nw.ShotSpec),
  [`CharacterRef`](_autosummary/nw.html.md#nw.CharacterRef), [`EnvironmentRef`](_autosummary/nw.html.md#nw.EnvironmentRef), [`SongInfo`](_autosummary/nw.html.md#nw.SongInfo).
- `nw.workflow` — the `prepare` → `plan` → `execute` render split
  (Plan/Execute over rendering; records render-result provenance).
- `nw.renderers` — render strategies.
- `nw.genres` — production genres (the reusable project specialization),
  their project factories, and the ops a host serves on a genre’s projects
  ([`GenreOp`](_autosummary/nw.html.md#nw.GenreOp), [`register_genre_ops()`](_autosummary/nw.html.md#nw.register_genre_ops), [`genre_ops_catalogue()`](_autosummary/nw.html.md#nw.genre_ops_catalogue)).
- `nw.pricing` — re-quoting a *persisted* plan at today’s rates
  ([`current_quote()`](_autosummary/nw.html.md#nw.current_quote), [`PlanQuote`](_autosummary/nw.html.md#nw.PlanQuote)). Any stored cost figure is an
  as-of-then fact; reporting one as current under-quotes the run once falaw’s
  rate tables move, so read it back through here (nw#74).

On rendering provenance and partial re-render (why choices, not just content,
are recorded as linked artifacts), see
`misc/docs/Rendering Provenance and Partial Re-render.md`.

### Functions

| [`parse_ref`](_autosummary/nw.html.md#nw.parse_ref)(text)                                    | The ordinal in a spoken reference, or `None` if it isn't one.                                                                        |
|-----------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------|
| [`format_ref`](_autosummary/nw.html.md#nw.format_ref)(n)                                      | The one spelling we print.                                                                                                           |
| [`genre_catalog`](_autosummary/nw.html.md#nw.genre_catalog)()                                    | Every registered genre as a JSON-able catalog entry (sorted by slug).                                                                |
| [`describe_genre`](_autosummary/nw.html.md#nw.describe_genre)(slug)                               | One genre's catalog entry (raises [`KeyError`](https://docs.python.org/3/builtins/exceptions.html#KeyError) if the slug is unknown). |
| [`recommend_genre`](_autosummary/nw.html.md#nw.recommend_genre)(kind)                              | The slug of the genre whose `intake_kinds` contains `kind` (first in slug order), or `None` when `kind` is falsy / unmatched.        |
| [`resolve_defaults`](_autosummary/nw.html.md#nw.resolve_defaults)(genre[, template])                | Resolve a genre (+ optional template) to the params for a new project.                                                               |
| [`register_genre_resolver`](_autosummary/nw.html.md#nw.register_genre_resolver)(slug, resolver)            | Register a resolver for a genre slug; returns it for inline use.                                                                     |
| [`resolve_genre`](_autosummary/nw.html.md#nw.resolve_genre)(genre[, template])                   | Resolve a genre (+ optional template) to the standard creation envelope.                                                             |
| [`register_genre_initializer`](_autosummary/nw.html.md#nw.register_genre_initializer)(slug, initializer)      | Register an initializer for a genre slug; returns it for inline use.                                                                 |
| [`initialize_genre`](_autosummary/nw.html.md#nw.initialize_genre)(genre, project, \*[, ...])        | Seed a freshly-created `project` for `genre` (+ optional `template`).                                                                |
| [`register_genre_project_factory`](_autosummary/nw.html.md#nw.register_genre_project_factory)(slug, factory)      | Register a project factory for a genre slug; returns it for inline use.                                                              |
| [`has_genre_project_factory`](_autosummary/nw.html.md#nw.has_genre_project_factory)(slug)                    | True iff a plugged-in project factory is registered for `slug`.                                                                      |
| [`can_place_genre_project`](_autosummary/nw.html.md#nw.can_place_genre_project)(slug)                      | True iff `slug`'s registered factory accepts host **placement**.                                                                     |
| [`create_genre_project`](_autosummary/nw.html.md#nw.create_genre_project)(genre, caller, ...[, ...])    | Create + seed a new project for a PLUGGED-IN `genre` in `caller`'s space.                                                            |
| [`register_genre_ops`](_autosummary/nw.html.md#nw.register_genre_ops)(genre_slug, ops)                | Register the operations a genre offers on its projects; returns them as a tuple.                                                     |
| [`genre_ops`](_autosummary/nw.html.md#nw.genre_ops)(genre_slug)                              | The ops registered for `genre_slug`, in registration order (`()` if none).                                                           |
| [`genre_op`](_autosummary/nw.html.md#nw.genre_op)(genre_slug, name)                         | The op `name` of `genre_slug`; [`UnknownGenreOpError`](_autosummary/nw.html.md#nw.UnknownGenreOpError) naming the known.                |
| [`genre_ops_catalogue`](_autosummary/nw.html.md#nw.genre_ops_catalogue)(genre_slug)                    | The pure-JSON catalogue of `genre_slug`'s ops (`[]` for a genre with none).                                                          |
| [`annotations_at_tier`](_autosummary/nw.html.md#nw.annotations_at_tier)(project_root, tier)            | Return every annotation at the given tier across all of the project's stores.                                                        |
| [`apply_to_projects`](_autosummary/nw.html.md#nw.apply_to_projects)(roots, fn, \*[, parallel])       | Apply `fn` to each project at `roots` and collect the results.                                                                       |
| [`clone_project`](_autosummary/nw.html.md#nw.clone_project)(src_root, dst_root, \*[, ...])       | Clone an nw project to a new root.                                                                                                   |
| [`backfill_traces`](_autosummary/nw.html.md#nw.backfill_traces)(project_root, \*[, execute])       | Bless a pre-trace project so the verifying-trace rule can read it (nw#58).                                                           |
| [`collect_orphan_traces`](_autosummary/nw.html.md#nw.collect_orphan_traces)(project_root)                | Drop verifying traces whose target annotation no longer exists.                                                                      |
| [`compose_report`](_autosummary/nw.html.md#nw.compose_report)(project, \*[, ...])                 | Per-shot reports + final-compose inspection in one call.                                                                             |
| [`derived_from`](_autosummary/nw.html.md#nw.derived_from)(project_root, annotation_id)          | Return the annotations this one was directly derived from.                                                                           |
| [`descendants_of`](_autosummary/nw.html.md#nw.descendants_of)(project_root, ancestor_id)          | Return every annotation whose provenance chain leads back to `ancestor_id`.                                                          |
| [`execute_render`](_autosummary/nw.html.md#nw.execute_render)(prep, plan, \*[, on_event, ...])    | Execute a Plan, materialize the result as `shot_dir/output.mp4`.                                                                     |
| [`execute_render_panel_images`](_autosummary/nw.html.md#nw.execute_render_panel_images)(project, ...[, ...])   | Execute `plan`, download each artifact, attach a PanelImage.                                                                         |
| [`get_genre`](_autosummary/nw.html.md#nw.get_genre)(slug)                                    | Look up a genre by slug; raises [`KeyError`](https://docs.python.org/3/builtins/exceptions.html#KeyError) with the known slugs.      |
| [`get_strategy`](_autosummary/nw.html.md#nw.get_strategy)(name)                                 | Look up a strategy by name; raises if unknown.                                                                                       |
| [`get_transform`](_autosummary/nw.html.md#nw.get_transform)(name)                                | Look up a Transform instance by name; raises with the known names.                                                                   |
| [`is_migrated`](_autosummary/nw.html.md#nw.is_migrated)(project_root)                          | True iff this project has been migrated to the lacing graph.                                                                         |
| [`iter_all_annotations`](_autosummary/nw.html.md#nw.iter_all_annotations)(project_root)                 | Walk every annotation in every store under a project (any backend).                                                                  |
| [`list_genres`](_autosummary/nw.html.md#nw.list_genres)()                                      | Return all registered genre slugs (sorted).                                                                                          |
| [`list_strategies`](_autosummary/nw.html.md#nw.list_strategies)()                                  | Return all registered strategy names (sorted).                                                                                       |
| [`list_transforms`](_autosummary/nw.html.md#nw.list_transforms)()                                  | Return all registered Transform names (sorted).                                                                                      |
| [`migrate_to_graph`](_autosummary/nw.html.md#nw.migrate_to_graph)(project_root, \*[, backup, ...])  | Migrate `project_root`'s project.json into the lacing graph.                                                                         |
| [`open_project_stores`](_autosummary/nw.html.md#nw.open_project_stores)(project_root)                  | Yield an iterator of open stores, one per scope, honouring the backend.                                                              |
| [`open_storyboard`](_autosummary/nw.html.md#nw.open_storyboard)(project)                           | Load the project's storyboard.                                                                                                       |
| [`plan_render_panel_images`](_autosummary/nw.html.md#nw.plan_render_panel_images)(storyboard, \*[, ...])    | Build a Plan that generates a seed image for each panel that lacks one.                                                              |
| [`plan_render_shot`](_autosummary/nw.html.md#nw.plan_render_shot)(prep, \*[, quality, ...])         | Build a `falaw.Plan` for rendering a prepared shot.                                                                                  |
| [`cost_records`](_autosummary/nw.html.md#nw.cost_records)(plan)                                 | The JSON-able per-call cost rows nw persists in a decision payload.                                                                  |
| [`current_quote`](_autosummary/nw.html.md#nw.current_quote)(plan, \*[, pricers])                 | Re-quote `plan` at today's rates and report the result honestly.                                                                     |
| [`plan_from_cost_records`](_autosummary/nw.html.md#nw.plan_from_cost_records)(records)                    | Rebuild a re-quotable `falaw.Plan` from [`cost_records()`](_autosummary/nw.html.md#nw.cost_records) rows.                        |
| [`quote_from_cost_records`](_autosummary/nw.html.md#nw.quote_from_cost_records)(records, \*[, pricers])    | Today's price for the calls stored in a decision payload.                                                                            |
| [`quote_render_decision`](_autosummary/nw.html.md#nw.quote_render_decision)(payload, \*[, pricers])      | Today's price for a `render_shot` decision payload.                                                                                  |
| [`unquotable`](_autosummary/nw.html.md#nw.unquotable)(reason)                                 | A quote for something that could not be re-quoted at all.                                                                            |
| [`prepare_shot`](_autosummary/nw.html.md#nw.prepare_shot)(project, shot_id, \*[, upload])       | Resolve all local inputs for rendering a shot.                                                                                       |
| [`project_asset_id`](_autosummary/nw.html.md#nw.project_asset_id)(project)                          | The asset_id used for storyboard panel references.                                                                                   |
| [`register_genre`](_autosummary/nw.html.md#nw.register_genre)(genre)                              | Register a [`Genre`](_autosummary/nw.html.md#nw.Genre) under its `slug`; returns it for inline use.                       |
| [`register_strategy`](_autosummary/nw.html.md#nw.register_strategy)(name, impl)                      | Register a strategy.                                                                                                                 |
| [`register_transform`](_autosummary/nw.html.md#nw.register_transform)(name[, impl, tags])             | Register a Transform under `name`.                                                                                                   |
| [`transform_catalog`](_autosummary/nw.html.md#nw.transform_catalog)()                                | Every registered Transform as a JSON-able capability entry (sorted by name).                                                         |
| [`stamp_transform_identity`](_autosummary/nw.html.md#nw.stamp_transform_identity)(plan, transform)          | Fold `transform.impl_version` into every call's cache identity.                                                                      |
| [`work_item_instance_id`](_autosummary/nw.html.md#nw.work_item_instance_id)(transform_name, ...)         | The instance id of one fan-out unit: UUIDv5 of `(transform_name, mapping_key)`.                                                      |
| [`fan_out_plan`](_autosummary/nw.html.md#nw.fan_out_plan)(transform, project, items, \*, ...)   | Plan one Transform across `items` — each unit an ordinary `plan()` call.                                                             |
| [`fan_out_execute`](_autosummary/nw.html.md#nw.fan_out_execute)(transform, project, fan_out, \*)   | Execute a planned fan-out, one ordinary `transform.execute` per unit.                                                                |
| [`as_secrets`](_autosummary/nw.html.md#nw.as_secrets)(secrets)                                | Coerce a caller-supplied mapping to [`Secrets`](_autosummary/nw.html.md#nw.Secrets); empty → `None`.                        |
| [`redact`](_autosummary/nw.html.md#nw.redact)(text, secrets)                              | `text` with every secret value replaced by `<redacted:name>`.                                                                        |
| [`redact_exception`](_autosummary/nw.html.md#nw.redact_exception)(error, secrets)                   | The exception to re-raise so that nothing it *renders* carries a secret.                                                             |
| [`using_secrets`](_autosummary/nw.html.md#nw.using_secrets)(secrets)                             | Bind the secrets nw itself knows how to use, for the duration of a block.                                                            |
| [`save_storyboard`](_autosummary/nw.html.md#nw.save_storyboard)(project, storyboard, \*, ...)      | Persist a Storyboard into the project's SqliteStore.                                                                                 |
| [`shot_report`](_autosummary/nw.html.md#nw.shot_report)(project, shot_id, \*[, ...])           | Inspect `shots/<shot_id>/output.mp4` and return a typed report.                                                                      |
| [`menu`](_autosummary/nw.html.md#nw.menu)(\*[, cost])                                   | Every registered check, name-ordered — what a user chooses from.                                                                     |
| [`plan_checks`](_autosummary/nw.html.md#nw.plan_checks)(selection)                             | Order the selection into waves that may each run concurrently.                                                                       |
| [`register_check`](_autosummary/nw.html.md#nw.register_check)([check])                            | Add a check to the menu, as a call or as a decorator.                                                                                |
| [`suggest`](_autosummary/nw.html.md#nw.suggest)(request, \*[, include_paid])               | Checks whose `example_requests` look like what the user just asked for.                                                              |
| [`validate`](_autosummary/nw.html.md#nw.validate)(target, \*[, checks, max_workers, ...])   | Run `checks` against `target` and report.                                                                                            |
| [`all_stale`](_autosummary/nw.html.md#nw.all_stale)(project_root)                            | Every annotation that is currently stale, regardless of cause.                                                                       |
| [`stale_after`](_autosummary/nw.html.md#nw.stale_after)(project_root, changed_id)              | Return every annotation that `changed_id` actually invalidated.                                                                      |
| [`stale_verdicts`](_autosummary/nw.html.md#nw.stale_verdicts)(project_root, changed_id)           | Classify every annotation downstream of `changed_id`.                                                                                |
| [`stale_verdicts_all`](_autosummary/nw.html.md#nw.stale_verdicts_all)(project_root)                   | Classify every derived annotation in the project — the snapshot form.                                                                |
| [`storyboard_db_path`](_autosummary/nw.html.md#nw.storyboard_db_path)(project)                        | Return the path to the project's storyboard SQLite store.                                                                            |
| [`storyboard_from_shots`](_autosummary/nw.html.md#nw.storyboard_from_shots)(project, \*[, title, style]) | Build a one-panel-per-shot draft Storyboard from a project's shots.                                                                  |
| [`summarize_all`](_autosummary/nw.html.md#nw.summarize_all)(roots)                               | Convenience: return a [`ProjectSummary`](_autosummary/nw.html.md#nw.ProjectSummary) for each project.                              |

### Classes

| [`Deliverable`](_autosummary/nw.html.md#nw.Deliverable)(path, content_type, filename[, ...])   | A finished thing a person can watch, hear, or download.                                                                     |
|-----------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------|
| [`Resolver`](_autosummary/nw.html.md#nw.Resolver)(\*args, \*\*kwargs)                       | `resolve(email, project_id, artifact_id) -> Deliverable` — a genre's half.                                                  |
| [`BaseTransform`](_autosummary/nw.html.md#nw.BaseTransform)()                                    | Default [`Transform`](_autosummary/nw.html.md#nw.Transform) implementation.                                          |
| [`CharacterImage`](_autosummary/nw.html.md#nw.CharacterImage)(path, \*[, from_ref, ...])          | One image associated with a character.                                                                                      |
| [`CharacterRef`](_autosummary/nw.html.md#nw.CharacterRef)(\*\*data)                             | Pointer to a character folder under `characters/<name>/`.                                                                   |
| [`ComposeReport`](_autosummary/nw.html.md#nw.ComposeReport)(\*\*data)                            | Inspection of the project-level final composed video.                                                                       |
| [`FreshnessVerdict`](_autosummary/nw.html.md#nw.FreshnessVerdict)(annotation, is_stale, reason)     | Why one reachable annotation was judged stale (or not).                                                                     |
| [`DecisionEntry`](_autosummary/nw.html.md#nw.DecisionEntry)(\*\*data)                            | One entry of a project's decision log, flattened for display.                                                               |
| [`EnvironmentRef`](_autosummary/nw.html.md#nw.EnvironmentRef)(\*\*data)                           | Pointer to an environment folder under `environments/<name>/`.                                                              |
| [`FrozenSegment`](_autosummary/nw.html.md#nw.FrozenSegment)(\*\*data)                            | A run of consecutive frames whose pixels don't change.                                                                      |
| [`Gap`](_autosummary/nw.html.md#nw.Gap)(\*\*data)                                      | A gap on the timeline between two shots.                                                                                    |
| [`Genre`](_autosummary/nw.html.md#nw.Genre)(slug, title[, description, ...])             | A reusable definition of a *production kind* over the nw substrate.                                                         |
| [`Template`](_autosummary/nw.html.md#nw.Template)(slug, title[, description, params])       | A named preset ("subgenre") *within* a genre — a filled-in default config.                                                  |
| [`GenreOp`](_autosummary/nw.html.md#nw.GenreOp)(name, fn, title[, description, ...])       | One operation a genre offers on its projects — a row a host builds surfaces from.                                           |
| [`Project`](_autosummary/nw.html.md#nw.Project)(root, \*[, auto_migrate])                  | A folder-backed nw project.                                                                                                 |
| [`ProjectGraph`](_autosummary/nw.html.md#nw.ProjectGraph)(project_root)                         | Typed read/write facade over the project's lacing graph store.                                                              |
| [`StoredUnproducedOutput`](_autosummary/nw.html.md#nw.StoredUnproducedOutput)(annotation_id, body)        |                                                                                                                             |
| [`UnproducedOutputBodyV1`](_autosummary/nw.html.md#nw.UnproducedOutputBodyV1)(\*\*data)                   | Body of an unproduced-output record.                                                                                        |
| [`ProjectSpec`](_autosummary/nw.html.md#nw.ProjectSpec)(\*\*data)                              | The top-level project SSOT, persisted as `project.json`.                                                                    |
| [`ProjectSummary`](_autosummary/nw.html.md#nw.ProjectSummary)(\*\*data)                           | Typed read view of a project — what `muvid status` printed, but typed.                                                      |
| [`ResumptionBrief`](_autosummary/nw.html.md#nw.ResumptionBrief)(\*\*data)                          | A "where we left off" snapshot, returned by [`nw.Project.resumption_brief()`](_autosummary/nw.html.md#nw.Project.resumption_brief). |
| [`SectionSpec`](_autosummary/nw.html.md#nw.SectionSpec)(\*\*data)                              | A non-overlapping span of the project's master timeline.                                                                    |
| [`ShotPreparation`](_autosummary/nw.html.md#nw.ShotPreparation)(project_root, shot, ...[, ...])    | Local-only inputs for rendering a single shot.                                                                              |
| [`ShotReport`](_autosummary/nw.html.md#nw.ShotReport)(\*\*data)                               | Inspection of one rendered shot.                                                                                            |
| [`ShotSpec`](_autosummary/nw.html.md#nw.ShotSpec)(\*\*data)                                 | A timeline-locked visual unit.                                                                                              |
| [`SongInfo`](_autosummary/nw.html.md#nw.SongInfo)(\*\*data)                                 | Metadata for the master audio file.                                                                                         |
| [`Strategy`](_autosummary/nw.html.md#nw.Strategy)(\*args, \*\*kwargs)                       | Render-strategy contract.                                                                                                   |
| [`Transform`](_autosummary/nw.html.md#nw.Transform)(\*args, \*\*kwargs)                      | A swappable, costed function from A-annotations to B-annotations.                                                           |
| [`TransformInputs`](_autosummary/nw.html.md#nw.TransformInputs)(primary[, context])                | The annotations a Transform consumes.                                                                                       |
| [`TransformResult`](_autosummary/nw.html.md#nw.TransformResult)(annotations[, artifacts, ...])     | The outputs of a Transform's [`execute()`](_autosummary/nw.html.md#nw.Transform.execute).                                    |
| [`FailedOutput`](_autosummary/nw.html.md#nw.FailedOutput)(skeleton, status[, reason, ...])      | An output annotation that was planned but never produced.                                                                   |
| [`PlanQuote`](_autosummary/nw.html.md#nw.PlanQuote)(\*, total_usd, status, ...[, reason])    | Today's price for a persisted plan, with the stale figure alongside.                                                        |
| [`WorkItem`](_autosummary/nw.html.md#nw.WorkItem)(\*\*data)                                 | One unit of a fan-out — the PDG-shaped work item (nw#26).                                                                   |
| [`FanOutUnit`](_autosummary/nw.html.md#nw.FanOutUnit)(item, instance_id, plan, skeleton)      | One planned unit: a work item plus its ordinary Transform plan.                                                             |
| [`FanOutPlan`](_autosummary/nw.html.md#nw.FanOutPlan)(transform_name, units)                  | The planned fan-out — pure data, like every plan in this federation.                                                        |
| [`FanOutItemResult`](_autosummary/nw.html.md#nw.FanOutItemResult)(item, instance_id, status)        | One unit's outcome, aligned 1:1 with the plan's units.                                                                      |
| [`FanOutResult`](_autosummary/nw.html.md#nw.FanOutResult)(transform_name, items)                | A fan-out run: one [`FanOutItemResult`](_autosummary/nw.html.md#nw.FanOutItemResult) per planned unit, in order.            |
| [`Secrets`](_autosummary/nw.html.md#nw.Secrets)([mapping])                                 | A read-only `{provider_name: key}` mapping that never prints or persists.                                                   |
| [`Check`](_autosummary/nw.html.md#nw.Check)(name, summary, run[, requires, ...])         | One validation, and everything a scheduler and a menu need to know.                                                         |
| [`CheckResult`](_autosummary/nw.html.md#nw.CheckResult)(name[, findings, skipped, ...])        | What one check produced, including the case where it could not run.                                                         |
| [`Finding`](_autosummary/nw.html.md#nw.Finding)(check, severity, message[, where, ...])    | One thing a check noticed.                                                                                                  |
| [`ValidationReport`](_autosummary/nw.html.md#nw.ValidationReport)(target[, results, elapsed_s])     | Everything a [`validate()`](_autosummary/nw.html.md#nw.validate) run produced.                                      |

### Exceptions

| [`CacheModeConflict`](_autosummary/nw.html.md#nw.CacheModeConflict)       | `use_cache=False` and `force=True` were passed together.                                       |
|--------------------------------------------------------------------------|------------------------------------------------------------------------------------------------|
| [`UnknownGenreOpError`](_autosummary/nw.html.md#nw.UnknownGenreOpError)     | `genre_op` was asked for a name the genre does not register.                                   |
| [`GenreOpRefused`](_autosummary/nw.html.md#nw.GenreOpRefused)          | An op DELIBERATELY declined — no song yet, an unknown clip, an edit that does not hold.        |
| [`GenreOpCancelled`](_autosummary/nw.html.md#nw.GenreOpCancelled)        | An op stopped because the host asked it to (its `should_cancel` returned True).                |
| [`ValidationError`](_autosummary/nw.html.md#nw.ValidationError)(report) | Raised by [`ValidationReport.raise_if_failed()`](_autosummary/nw.html.md#nw.ValidationReport.raise_if_failed). |

### *class* nw.BaseTransform

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

Default [`Transform`](_autosummary/nw.html.md#nw.Transform) implementation.

Subclasses set the class attributes (`name`, `input_kinds`,
`output_kind`, optionally `params_model`) and implement `plan()`.
The default `execute()` runs the Plan, maps artifacts onto skeletons
1:1 via `_complete_annotation()`, writes to the project graph, and
reports cost. Transforms whose artifact→annotation mapping is not 1:1
(e.g. `clips_to_animatic`: N inputs → 1 output) override `execute()`.

That 1:1 mapping is an **invariant, checked before anything is spent**:
`execute()` raises when `len(skeleton) != len(plan.calls)`, the
same guard `nw.storyboard.execute_render_panel_images` already applies
to its own plan/id pairing. The zip below would otherwise stop at the
shorter sequence and drop the surplus with no error and no record —
harmless only for as long as the executor returns exactly one artifact
per call, which is precisely what per-call failure isolation changes.

#### generate_when *: [Literal](https://docs.python.org/3/library/typing.html#typing.Literal)['static', 'dynamic']* *= 'dynamic'*

When this Transform’s fan-out cardinality is knowable — `"static"`
or `"dynamic"` (nw#26). The default is `"dynamic"`: fail
expensive-looking, so an undeclared shape can never let a cost gate
quote a number for a cardinality nobody knows yet. Declare `"static"`
only when the work-item list is derivable from the graph before the
run (“one image per panel”).

#### impl_version *: [str](https://docs.python.org/3/builtins/stdtypes.html#str)* *= '1'*

Behaviour version — bump on “same interface, changed behaviour”, never
rename the registry key for it. See the [`Transform`](_autosummary/nw.html.md#nw.Transform) Protocol for
the full contract. At the default, no cache salt is applied, so every
key ever issued stays byte-identical; the first real bump is the first
salt.

#### is_batch *: [bool](https://docs.python.org/3/builtins/functions.html#bool)* *= False*

Whether `plan()` consumes all of `inputs.primary` at once (batch)
or a single primary annotation (one-to-one — the default). See the
[`Transform`](_autosummary/nw.html.md#nw.Transform) Protocol for the full contract. Batch Transforms
(`extract_*`, `clips_to_animatic`) set this to `True`.

#### params_model

alias of [`None`](https://docs.python.org/3/builtins/constants.html#None)

### *exception* nw.CacheModeConflict

Bases: [`ValueError`](https://docs.python.org/3/builtins/exceptions.html#ValueError)

`use_cache=False` and `force=True` were passed together.

A `ValueError` subclass so callers that already catch `ValueError`
(and falaw’s own refusal of the same corner) keep working, while a caller
that wants to distinguish this one specific contradiction can.

### *class* nw.CharacterImage(path, , from_ref=False, from_selected=False, is_anchor=False)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

One image associated with a character.

Returned by [`Project.list_character_images()`](_autosummary/nw.html.md#nw.Project.list_character_images). Distinguishes:

- `from_ref`: file lives under `characters/<name>/refs/` — a candidate
  from generation or upload.
- `from_selected`: under `characters/<name>/selected/` — curator-picked.
- `is_anchor`: this is the file the character card currently points at as
  the “use this image” anchor (lipsync seed, etc.).

### *class* nw.CharacterRef(\*\*data)

Bases: `BaseModel`

Pointer to a character folder under `characters/<name>/`.

The stable-attribute fields mirror
[`nw.bodies.CharacterRefBodyV1`](_autosummary/nw.bodies.html.md#nw.bodies.CharacterRefBodyV1) field-for-field, and that is
load-bearing rather than cosmetic: [`nw.Project.read_spec()`](_autosummary/nw.html.md#nw.Project.read_spec) builds
a `CharacterRef` from the graph body and
[`nw.Project.write_spec()`](_autosummary/nw.html.md#nw.Project.write_spec) writes the body back from the
`CharacterRef`. Any field present on the body but missing here is
**silently erased** by the next `update_spec` — which is what used to
happen to `reference_image_urls`. Add a field to one, add it to both.

#### model_config *: [ClassVar](https://docs.python.org/3/library/typing.html#typing.ClassVar)[ConfigDict]* *= {'extra': 'ignore'}*

Configuration for the model, should be a dictionary conforming to [`ConfigDict`][pydantic.config.ConfigDict].

### *class* nw.Check(name, summary, run, requires=(), parallel_safe=True, cost='cheap', example_requests=(), requires_binaries=())

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

One validation, and everything a scheduler and a menu need to know.

#### name

dotted and stable — it is what a user selects and what a
`requires` refers to.

#### summary

one line, for the menu.

#### run

`(target, context) -> findings`. May also return a
`(findings, produced)` pair when other checks depend on it.

#### requires

names of checks that must run first, whose `produced`
values arrive in `context`. Cycles raise at plan time.

#### parallel_safe

whether it may run alongside its independent peers.
`False` for anything that is not thread-safe or that saturates
the machine on its own (a full decode).

#### cost

rough wall-clock class — `"free"` (no subprocess),
`"cheap"` (seconds), `"dear"` (a full pass over the media), or
`"paid"` (spends money, e.g. a hosted OCR or transcription).
`"paid"` is never selected by [`suggest()`](_autosummary/nw.html.md#nw.suggest); it must be asked
for by name.

#### example_requests

things a person actually says that mean they want
this check. What lets a menu of forty be navigated, and what an
MCP surface matches against instead of exposing an enum.

#### requires_binaries

external programs it shells out to. A missing one
makes the check *skip with a reason*, never silently pass.

### *class* nw.CheckResult(name, findings=(), skipped='', error='', elapsed_s=0.0, produced=None)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

What one check produced, including the case where it could not run.

`skipped` and `error` are kept distinct from “found nothing”, because
conflating them is how a validation suite comes to report all-clear on a
machine where half of it never ran. A missing binary is not a pass.

#### *property* ok *: [bool](https://docs.python.org/3/builtins/functions.html#bool)*

Ran, and found nothing at or above `FAILING_SEVERITY`.

### *class* nw.ComposeReport(\*\*data)

Bases: `BaseModel`

Inspection of the project-level final composed video.

#### model_config *: [ClassVar](https://docs.python.org/3/library/typing.html#typing.ClassVar)[ConfigDict]* *= {'frozen': True}*

Configuration for the model, should be a dictionary conforming to [`ConfigDict`][pydantic.config.ConfigDict].

### *class* nw.DecisionEntry(\*\*data)

Bases: `BaseModel`

One entry of a project’s decision log, flattened for display.

#### model_config *: [ClassVar](https://docs.python.org/3/library/typing.html#typing.ClassVar)[ConfigDict]* *= {'extra': 'ignore'}*

Configuration for the model, should be a dictionary conforming to [`ConfigDict`][pydantic.config.ConfigDict].

### *class* nw.Deliverable(path, content_type, filename, artifact_id='', project_id='', genre='', ref=None, title=None, duration_s=None, size_bytes=None, created_at=None, meta=<factory>)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

A finished thing a person can watch, hear, or download.

`path` is server-side and never leaves the host; it is what the transport
streams. Everything else exists so the host does not have to guess:

- `content_type` — what to serve it as. The genre knows; the host would
  otherwise re-derive it from a suffix.
- `filename` — what it should be called when it lands in someone’s
  Downloads folder. `music_video_test_02-cut-4.mp4` beats `b02fc05417ea`.
- `ref` — the speakable label (see [`format_ref()`](_autosummary/nw.html.md#nw.format_ref)).
- `artifact_id` — the stable, unambiguous id. `ref` is the convenience;
  this is the truth, and it is what a signed token is minted against.

The optional descriptive fields are what a listing surface renders, and what
lets a watch page say “10 seconds, 4.4 MB, made yesterday” without opening
the file.

#### *property* kind *: [str](https://docs.python.org/3/builtins/stdtypes.html#str)*

`'video'`, `'audio'`, `'image'` or `'file'` — how to present it.

Derived from `content_type` so a genre never has to declare it twice.

```pycon
>>> Deliverable(Path('a.mp4'), 'video/mp4', 'a.mp4').kind
'video'
>>> Deliverable(Path('a.mp3'), 'audio/mpeg', 'a.mp3').kind
'audio'
>>> Deliverable(Path('a.pdf'), 'application/pdf', 'a.pdf').kind
'file'
```

#### *property* label *: [str](https://docs.python.org/3/builtins/stdtypes.html#str)*

The best short name for a human — the ref if it has one, else the id.

```pycon
>>> Deliverable(Path('a.mp4'), 'video/mp4', 'a.mp4', ref='cut 4').label
'cut 4'
>>> Deliverable(Path('a.mp4'), 'video/mp4', 'a.mp4', artifact_id='b02f').label
'b02f'
```

#### meta *: [dict](https://docs.python.org/3/builtins/stdtypes.html#dict)*

Genre-specific extras a listing or watch page may show. Free-form on
purpose — the host renders what it recognises and ignores the rest, so a
genre can enrich its own surface without a change here.

### *class* nw.EnvironmentRef(\*\*data)

Bases: `BaseModel`

Pointer to an environment folder under `environments/<name>/`.

Mirrors [`nw.bodies.EnvironmentRefBodyV1`](_autosummary/nw.bodies.html.md#nw.bodies.EnvironmentRefBodyV1) field-for-field, for the
same load-bearing reason as [`CharacterRef`](_autosummary/nw.html.md#nw.CharacterRef) — see that docstring.
`reference_image_urls` (the lookbook the FE curates for a *location*)
was erased by every `update_spec` until this mirror was completed.

#### model_config *: [ClassVar](https://docs.python.org/3/library/typing.html#typing.ClassVar)[ConfigDict]* *= {'extra': 'ignore'}*

Configuration for the model, should be a dictionary conforming to [`ConfigDict`][pydantic.config.ConfigDict].

### *class* nw.FailedOutput(skeleton, status, reason='', error=None, blocked_by=())

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

An output annotation that was planned but never produced.

Carries the *skeleton* rather than an id because the skeleton is what the
caller planned and what a retry would re-submit — and because a UI needs its
body to say which panel is missing, not just that something is.

#### blocked_by *: [tuple](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[int](https://docs.python.org/3/builtins/functions.html#int), ...]*

Indices of the calls whose failure blocked this one.

#### error *: [BaseException](https://docs.python.org/3/builtins/exceptions.html#BaseException) | [None](https://docs.python.org/3/builtins/constants.html#None)*

The original exception, for a caller that classifies on falaw’s typed
hierarchy (`FalRateLimited` is worth retrying; `FalAccountLocked` is not).

#### reason *: [str](https://docs.python.org/3/builtins/stdtypes.html#str)*

upstream panel 47
was filtered”\* rather than an unexplained hole.

* **Type:**
  Human-readable cause, from falaw. Renders as 

  ```
  *
  ```

  ”skipped

#### skeleton *: Annotation*

The annotation that would have been completed.

#### status *: [str](https://docs.python.org/3/builtins/stdtypes.html#str)*

`"failed"` (its own call failed) or `"blocked"` (an upstream one did).

### *class* nw.FanOutItemResult(item, instance_id, status, result=None, error=None, reason='')

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

One unit’s outcome, aligned 1:1 with the plan’s units.

### *class* nw.FanOutPlan(transform_name, units)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

The planned fan-out — pure data, like every plan in this federation.

Cost arithmetic follows falaw#18’s honest form exactly (same names, same
semantics): [`known_cost_usd`](_autosummary/nw.html.md#nw.FanOutPlan.known_cost_usd) is the priced part, and a correct
gate reads it **together with** [`unknown_call_count`](_autosummary/nw.html.md#nw.FanOutPlan.unknown_call_count) — the true
cost is the known sum *plus an unknown amount* over that many calls,
and the gate refuses when the count is nonzero rather than pretending
the unknown part is free.

#### *property* has_unknown_costs *: [bool](https://docs.python.org/3/builtins/functions.html#bool)*

True if any unit has a billable call with no price.

#### *property* known_cost_usd *: [float](https://docs.python.org/3/builtins/functions.html#float)*

Sum of every unit plan’s priced, non-cache-hit calls.

#### *property* unknown_call_count *: [int](https://docs.python.org/3/builtins/functions.html#int)*

How many billable calls across all units carry no price.

### *class* nw.FanOutResult(transform_name, items)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

A fan-out run: one [`FanOutItemResult`](_autosummary/nw.html.md#nw.FanOutItemResult) per planned unit, in order.

`len(result.items) == len(fan_out.units)` always — the same alignment
guarantee falaw’s `ExecutionReport` gives one level down.

#### *property* cost_usd_actual *: [float](https://docs.python.org/3/builtins/functions.html#float)*

Observed spend over the units that ran. A lower bound, like
[`TransformResult.cost_usd_actual`](_autosummary/nw.html.md#nw.TransformResult.cost_usd_actual) (whose caveat about billed-
but-failed calls applies per unit).

#### *property* has_unknown_costs *: [bool](https://docs.python.org/3/builtins/functions.html#bool)*

True when the run’s true spend is not fully known.

Two sources, both counted: a surviving unit whose own report says
so, and any **failed** unit — a unit that raised mid-execute may
have been billed for calls its (discarded) report would have
carried, so its spend is unknown by construction. Without the
second clause, a failed run could read “all costs known, $0.00
spent” — the exact under-report the federation’s unknown-cost rule
exists to prevent. `blocked` units never ran and are known-$0.

#### to_record()

The run record — where work items live (never the graph document).

JSON-serializable as returned, provided every item’s `attributes`
is (their contract; a violation raises here, naming the item).
Annotations and artifacts are referenced by id; the annotations
themselves were already written to the graph by each unit’s ordinary
`execute`, and duplicating their bodies here would make the record
a second, driftable copy.

`failed_count` / `blocked_count` count outputs **within** a unit
(zero when the unit itself failed — its result is `None`); the
unit-level outcome is `status`. A consumer counting failed *units*
counts statuses, not these fields.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### *class* nw.FanOutUnit(item, instance_id, plan, skeleton)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

One planned unit: a work item plus its ordinary Transform plan.

### *class* nw.Finding(check, severity, message, where='', remedy=None, evidence=<factory>)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

One thing a check noticed.

A finding is never a bare boolean. Whoever reads it — a human deciding
whether to publish, or a model deciding what to fix — needs to know *where*
in the work it is and *what* would make it go away, and a check that cannot
say those two things has not finished its job.

#### check

the name of the check that produced it.

#### severity

`"info"`, `"warn"` or `"error"`; only `"error"`
makes a report not `ok`.

#### message

what is wrong, in one sentence a human can act on.

#### where

where in the work — a timestamp, a frame index, a shot id, a
path. Free-form because the checks are, but never empty for
anything above `"info"`.

#### remedy

what would fix it, when the check knows. `None` when it
honestly does not.

#### evidence

anything a reader would want to look at — an extracted
frame’s path, the numbers behind the verdict.

### *class* nw.FreshnessVerdict(annotation, is_stale, reason, upstream_id=None)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

Why one reachable annotation was judged stale (or not).

Emitted by [`stale_verdicts()`](_autosummary/nw.html.md#nw.stale_verdicts). `reason` is one of the
`REASON_*` constants; `upstream_id` names the parent that decided it
when a single parent did, so “why is this stale?” has an answer that does
not require re-deriving the walk by hand.

### *class* nw.FrozenSegment(\*\*data)

Bases: `BaseModel`

A run of consecutive frames whose pixels don’t change.

A short freeze (≤ 0.25s) is usually a model artifact; a long one (≥ 1s)
is almost always a bug — Hailuo Pro returning a too-short clip + a tpad
fallback that froze the last frame, etc.

#### model_config *: [ClassVar](https://docs.python.org/3/library/typing.html#typing.ClassVar)[ConfigDict]* *= {}*

Configuration for the model, should be a dictionary conforming to [`ConfigDict`][pydantic.config.ConfigDict].

### *class* nw.Gap(\*\*data)

Bases: `BaseModel`

A gap on the timeline between two shots.

#### model_config *: [ClassVar](https://docs.python.org/3/library/typing.html#typing.ClassVar)[ConfigDict]* *= {'frozen': True}*

Configuration for the model, should be a dictionary conforming to [`ConfigDict`][pydantic.config.ConfigDict].

### *class* nw.Genre(slug, title, description='', body_schema_uris=(), transform_names=(), strategy_names=(), projection_entrypoint=None, folder_conventions=<factory>, status='available', templates=(), intake_kinds=(), cost_profile=None, defaults=<factory>)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

A reusable definition of a *production kind* over the nw substrate.

Pure data: it *references* substrate pieces by name rather than owning
them, so declaring a genre never touches the engine.

```pycon
>>> slideshow = Genre(
...     slug="slideshow",
...     title="Slideshow",
...     description="Stills over narration, assembled to a video.",
...     transform_names=("clips_to_animatic.ffmpeg",),
...     projection_entrypoint="clips_to_animatic.ffmpeg",
... )
>>> slideshow.title
'Slideshow'
>>> slideshow.status
'available'
```

`projection_entrypoint`, when given, must be one of the genre’s own
declared transforms or strategies:

```pycon
>>> Genre(slug="bad", title="Bad", projection_entrypoint="nope")
Traceback (most recent call last):
    ...
ValueError: Genre 'bad': projection_entrypoint 'nope' is not among its transform_names or strategy_names
```

#### cost_profile *: [str](https://docs.python.org/3/builtins/stdtypes.html#str) | [None](https://docs.python.org/3/builtins/constants.html#None)* *= None*

A short discriminator slug routing the cost gate to the right estimator
(e.g. `"tts"` = per-character audio, `"per_clip"` = per-render video).
The real numbers stay in the app; this is only the routing tag.

#### defaults *: [Mapping](https://docs.python.org/3/library/collections.abc.html#collections.abc.Mapping)[[str](https://docs.python.org/3/builtins/stdtypes.html#str), [Any](https://docs.python.org/3/library/typing.html#typing.Any)]*

The “start from scratch” params for this genre (same opaque shape as a
[`Template`](_autosummary/nw.html.md#nw.Template)’s `params`) — used when no template is chosen.

#### intake_kinds *: [tuple](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[str](https://docs.python.org/3/builtins/stdtypes.html#str), ...]* *= ()*

Intake “what are you making?” answers that select this genre (the edge
[`recommend_genre()`](_autosummary/nw.html.md#nw.recommend_genre) walks). App data (e.g. reelee’s intake form) owns
the vocabulary; the genre just declares which answers it covers.

#### is_ready()

True iff every declared transform and strategy is registered.

A `planned` genre may legitimately be *not* ready; an `available`
one that isn’t ready is a wiring bug worth catching in a test.

* **Return type:**
  [`bool`](https://docs.python.org/3/builtins/functions.html#bool)

#### list_templates()

This genre’s Template slugs, in declared order.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]

#### missing_strategies()

Declared `strategy_names` not (yet) present in `nw.renderers`.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]

#### missing_transforms()

Declared `transform_names` not (yet) present in `nw.transforms`.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]

#### template(slug)

Look up one of this genre’s [`Template`](_autosummary/nw.html.md#nw.Template)s by slug (KeyError if absent).

* **Return type:**
  [`Template`](_autosummary/nw.html.md#nw.Template)

#### templates *: [tuple](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[Template](_autosummary/nw.html.md#nw.Template), ...]* *= ()*

Named presets (“subgenres”) within this genre — see [`Template`](_autosummary/nw.html.md#nw.Template).

#### to_dict()

A JSON-able catalog entry — the shape apps serve to a frontend / MCP client.

Templates are emitted with their opaque `params` (not flattened), and
`intake_kinds`/`cost_profile`/`defaults` ride at the genre level, so a
consumer needs no app-specific knowledge to render the catalog.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### *class* nw.GenreOp(name, fn, title, description='', effect='write', runs='now', host_params=(), max_upload_bytes=None, spends=False)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

One operation a genre offers on its projects — a row a host builds surfaces from.

`fn(project, **params) -> dict` does the work; `params` and the result are
JSON-able. `title` is a short plain-language imperative (“Find where each video
fits”) — it becomes a button or command title. `description` is model-facing and
defaults to `fn`’s docstring. `effect` is one of `GENRE_OP_EFFECTS`,
`runs` one of `GENRE_OP_RUNS`.

Construction validates everything, including that every parameter after the
project is annotated with a JSON-describable type — a bad op fails where it is
declared, not in front of a user.

`host_params` names parameters the HOST supplies, never a client: a streamed
upload’s temporary `path`, its original `filename`. They are left out of
[`params_schema`](_autosummary/nw.html.md#nw.GenreOp.params_schema) (so a client that sends one fails validation — the schema is
`additionalProperties: false`), they must be keyword parameters of `fn`, and
[`run()`](_autosummary/nw.html.md#nw.GenreOp.run) passes them through from its `host` argument without validating them
— the host produced them. A host reads `host_params` in [`to_dict()`](_autosummary/nw.html.md#nw.GenreOp.to_dict) to know
which ops take an upload. This is the boundary that stops a generic op route from
letting a client name a file on the server.

Two host parameters have agreed meanings: an upload’s `path` (with
`max_upload_bytes`, the op’s own ceiling the host enforces WHILE streaming, before
the op ever runs) and `CANCEL_PARAM` (a zero-argument callable the op polls,
raising [`GenreOpCancelled`](_autosummary/nw.html.md#nw.GenreOpCancelled)). A deliberate refusal is a
[`GenreOpRefused`](_autosummary/nw.html.md#nw.GenreOpRefused).

`spends` says the op MAY spend money (it can reach a paid API). The federation’s
rule is that an unknown cost forces approval, so a host must route a `spends` op
through its money approval — or not offer it. `False` (the default) is a claim the
genre makes: nothing this op calls bills anyone.

```pycon
>>> def _rename(project, *, title: str, loud: bool = False) -> dict:
...     '''Rename the project.'''
...     return {"title": title.upper() if loud else title}
>>> op = GenreOp("rename", _rename, title="Rename it")
>>> op.description, op.effect, op.runs
('Rename the project.', 'write', 'now')
>>> sorted(op.params_schema["properties"]), op.params_schema["required"]
(['loud', 'title'], ['title'])
>>> op.run(None, {"title": "x", "loud": True})
{'title': 'X'}
```

An op taking an upload, whose `path` only the host may give:

```pycon
>>> def _ingest(project, *, path: str, name: str = "") -> dict:
...     return {"path": path, "name": name}
>>> up = GenreOp("ingest", _ingest, title="Add a file", host_params=("path",))
>>> list(up.params_schema["properties"]), up.to_dict()["host_params"]
(['name'], ['path'])
>>> up.run(None, {"name": "a"}, host={"path": "/tmp/upload"})
{'path': '/tmp/upload', 'name': 'a'}
```

#### *property* params_model

The pydantic model of the op’s parameters (`extra="forbid"`).

#### *property* params_schema *: [dict](https://docs.python.org/3/builtins/stdtypes.html#dict)*

JSON Schema (an object, `additionalProperties: false`) of the parameters.

#### run(project, params=None, , host=None)

Run the op on `project`: `params` (the CLIENT’s, validated against
[`params_schema`](_autosummary/nw.html.md#nw.GenreOp.params_schema)) plus `host` (the host’s, passed through as given).

`host` may carry only the op’s declared `host_params`; anything else is
a host bug and raises [`TypeError`](https://docs.python.org/3/builtins/exceptions.html#TypeError), as does a required host parameter the
host did not supply. A client parameter that is missing, unknown (a host
parameter included) or mistyped raises `pydantic.ValidationError`.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

#### to_dict()

The op’s JSON row: everything but the function.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

#### validate_params(params=None)

`params` checked and coerced against [`params_schema`](_autosummary/nw.html.md#nw.GenreOp.params_schema).

Raises `pydantic.ValidationError` (a [`ValueError`](https://docs.python.org/3/builtins/exceptions.html#ValueError)) on a missing,
unknown or wrongly typed parameter. Only the parameters the caller passed are
returned, so the op’s own defaults stay the op’s.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### *exception* nw.GenreOpCancelled

Bases: [`Exception`](https://docs.python.org/3/builtins/exceptions.html#Exception)

An op stopped because the host asked it to (its `should_cancel` returned True).

Not a refusal and not a failure: a host that cancelled a job records it as
cancelled. Raised by the op, between steps, when it was given a
`CANCEL_PARAM` host parameter that says stop.

### *exception* nw.GenreOpRefused

Bases: [`ValueError`](https://docs.python.org/3/builtins/exceptions.html#ValueError)

An op DELIBERATELY declined — no song yet, an unknown clip, an edit that does not
hold. The base a genre derives its refusal type from (muvid’s `FootageError`).

A host maps exactly this to a client-facing refusal (reelee: `422`); any other
exception out of an op — a plain `ValueError` included — is a bug, reported as
one (`500` with the traceback logged). A `ValueError` so code already catching
that keeps working.

### *class* nw.PlanQuote(, total_usd, status, as_of_total_usd, repriced, reason='')

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

Today’s price for a persisted plan, with the stale figure alongside.

The stale figure is kept — as [`as_of_total_usd`](_autosummary/nw.html.md#nw.PlanQuote.as_of_total_usd), explicitly named
“as of then” — because an audit surface wants to show the movement. What
it must never do is *present* it as current; that is what
[`total_usd`](_autosummary/nw.html.md#nw.PlanQuote.total_usd) and [`status`](_autosummary/nw.html.md#nw.PlanQuote.status) are for.

#### as_of_total_usd *: [float](https://docs.python.org/3/builtins/functions.html#float) | [None](https://docs.python.org/3/builtins/constants.html#None)*

What the persisted plan said, `None` if it already said unknown.

A fact about the moment it was written. Render it labelled as such, or
not at all.

#### *property* basis_changed *: [bool](https://docs.python.org/3/builtins/functions.html#bool)*

True when a rate table moved underneath at least one call.

The audit answer the frozen number could never give: it separates “the
price changed because the *table* changed” from “the price changed
because the plan did”. Read it beside [`status`](_autosummary/nw.html.md#nw.PlanQuote.status) — a `changed`
with this `False` is a caller quoting different quantities, not a
repricing event.

#### *property* delta_usd *: [float](https://docs.python.org/3/builtins/functions.html#float) | [None](https://docs.python.org/3/builtins/constants.html#None)*

`total_usd - as_of_total_usd`, or `None` when either is unknown.

`None` rather than `0.0`: a plan that lost its price did not move
by zero.

#### *property* has_unknown_costs *: [bool](https://docs.python.org/3/builtins/functions.html#bool)*

True when the total cannot be known — the gate’s refusal condition.

The same judgement as `falaw.Plan.has_unknown_costs`, made at
re-quote time rather than at plan time.

#### reason *: [str](https://docs.python.org/3/builtins/stdtypes.html#str)*

Why the *whole* quote is unknown, when that is the situation.

Set by [`unquotable()`](_autosummary/nw.html.md#nw.unquotable) (the thing handed in was not a plan) and by
[`quote_render_decision()`](_autosummary/nw.html.md#nw.quote_render_decision) (the payload contradicts itself). Empty for
an ordinary re-quote, where the per-call reasons live on
[`repriced`](_autosummary/nw.html.md#nw.PlanQuote.repriced) instead.

#### repriced *: RepricedPlan*

falaw’s per-call diff — `status`, `basis_changed`, `reason` per
call. Read it for the audit view; [`total_usd`](_autosummary/nw.html.md#nw.PlanQuote.total_usd) is the headline.

#### status *: [Literal](https://docs.python.org/3/library/typing.html#typing.Literal)['unchanged', 'changed', 'unknown']*

Which of the three cases this plan fell into — see `QuoteStatus`.

#### to_dict()

JSON-able headline for a surface (an API response, a job record).

Deliberately not the whole per-call diff: a spend surface needs the
number, whether it is knowable, and whether it moved. Reach into
[`repriced`](_autosummary/nw.html.md#nw.PlanQuote.repriced) for the rest.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]

#### total_usd *: [float](https://docs.python.org/3/builtins/functions.html#float) | [None](https://docs.python.org/3/builtins/constants.html#None)*

Today’s billable total, or `None` when any billable call is
unpriceable today. `None` means unknown, never free (nw invariant #2).

### *class* nw.Project(root, , auto_migrate=True)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

A folder-backed nw project.

Construct from a path (must exist + must contain `project.json`); use
[`Project.init()`](_autosummary/nw.html.md#nw.Project.init) to bootstrap a new project on disk.

#### add_character(name, , description='')

Add a character (idempotent: re-adds update the description).

Re-adding updates *only* the description: any stable attributes
already recorded on the character (costume, palette anchors,
`do_not_do` …) are carried over, so calling this again is not a
way to lose them.

* **Return type:**
  [`CharacterRef`](_autosummary/nw.schema.html.md#nw.schema.CharacterRef)

#### *classmethod* init(root, , title='', song=None, force=False)

Create a new project on disk and return the [`Project`](_autosummary/nw.html.md#nw.Project) facade.

* **Parameters:**
  * **root** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str) | [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)) – Folder to create. Must not exist (or pass `force=True` to
    overwrite an empty folder).
  * **title** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str)) – Optional human-readable title; defaults to the folder name.
  * **song** (`Union`[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path), [`None`](https://docs.python.org/3/builtins/constants.html#None)]) – Optional path to a master audio file. When given, the file
    is *copied* into `<root>/song/` and registered in the spec.
  * **force** ([`bool`](https://docs.python.org/3/builtins/functions.html#bool)) – When True, accept an existing folder if it’s empty (no
    `project.json`); refuse if a project already exists there.
* **Return type:**
  [`Project`](_autosummary/nw.project.html.md#nw.project.Project)

#### list_character_images(name)

Return all images associated with a character, with provenance flags.

Walks `characters/<name>/refs/` and `characters/<name>/selected/`.
Marks the file the card’s `reference_image_path` points at as
`is_anchor=True`.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`CharacterImage`](_autosummary/nw.project.html.md#nw.project.CharacterImage)]

#### log_decision(kind, \*\*payload)

Record a typed decision in the project graph + the JSONL audit log.

Decisions are project-local provenance: which character anchor was
picked, which model overrode the default, why a shot was retried.
Both surfaces stay in sync:

- The lacing graph (`decision` tier, body schema
  `annot://schema/decision/v1`) is the SSOT — reelee will surface
  these in inspector / network views.
- `.nw/decisions.jsonl` continues as a tail-grep-able audit trail.

* **Return type:**
  [`None`](https://docs.python.org/3/builtins/constants.html#None)

#### read_spec()

Read the project spec, synthesizing from the graph for graph-native fields.

Project-level metadata (title, song, global_style, notes,
schema_version) lives in `project.json`. Sections, shots,
characters, and environments live in the lacing graph and are
synthesized into the returned [`ProjectSpec`](_autosummary/nw.html.md#nw.ProjectSpec) for back-compat
with code that still reads via `read_spec()`.

* **Return type:**
  [`ProjectSpec`](_autosummary/nw.schema.html.md#nw.schema.ProjectSpec)

#### read_summary()

Return a typed read view of the project — all the facts at once.

* **Return type:**
  [`ProjectSummary`](_autosummary/nw.schema.html.md#nw.schema.ProjectSummary)

#### resolved_genre()

The `{genre, template, params}` envelope this project was created as.

The read accessor for the envelope `nw.genres.initialize_genre()`
persists at creation (nw#32) — same shape as
`nw.genres.resolve_genre()` returns, so consumers reuse or diff
the *effective* creation params without re-deriving them. `None`
for a project with no recorded genre (created before nw#32, or not
through the genre machinery).

* **Return type:**
  [`Optional`](https://docs.python.org/3/library/typing.html#typing.Optional)[[`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)]

#### resumption_brief(, recent=10)

Return a “where we left off” snapshot for the start of a session.

Pure data, fully offline: a decision-log tail, what is reachable
downstream of the last change, recorded spend, and a deterministic
list of suggested next actions. reelee renders it as prose and
injects it as the opening context of a session.

Read [`ResumptionBrief`](_autosummary/nw.schema.html.md#nw.schema.ResumptionBrief) before trusting the numbers —
two of them are upper bounds, and the brief says so in
`caveats` rather than only in a
docstring.

* **Parameters:**
  **recent** ([`int`](https://docs.python.org/3/builtins/functions.html#int)) – How many decision-log entries to include, most recent last.
* **Return type:**
  [`ResumptionBrief`](_autosummary/nw.schema.html.md#nw.schema.ResumptionBrief)

#### set_character_anchor(name, image_path)

Pick an existing image as the character’s anchor (lipsync seed, etc.).

Returns the updated card. Raises if the image isn’t under the
character’s folder, since cross-character anchoring is almost always
a mistake.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]

#### set_global_style(style)

Set the project-level visual style hint.

* **Return type:**
  [`ProjectSpec`](_autosummary/nw.schema.html.md#nw.schema.ProjectSpec)

#### set_song(source, , copy=True)

Register an audio file as the project’s master song.

Probes duration / sample-rate / bitrate via `mixing.audio.Audio` if
available, else leaves them at 0 (the spec accepts the SSOT-only
path with placeholder metadata).

* **Return type:**
  [`SongInfo`](_autosummary/nw.schema.html.md#nw.schema.SongInfo)

#### set_title(title)

Set the project title.

* **Return type:**
  [`ProjectSpec`](_autosummary/nw.schema.html.md#nw.schema.ProjectSpec)

#### total_spend_usd()

Sum the cost recorded on every decision in the project.

Prefers each decision’s *actual* per-artifact `cost_usd` and falls
back to its `total_estimated_cost_usd` when no artifact costs were
recorded.

**Deliberately not re-quoted.** This is money that was *billed*, and a
receipt is not a quote: re-pricing it through
[`nw.pricing.current_quote()`](_autosummary/nw.pricing.html.md#nw.pricing.current_quote) would rewrite history at today’s
rates. The consequence is that the estimate-based fallback is an
as-of-then figure — falaw’s tables have moved since (0.0.46 tenfold
upward on premium LLM calls), so a decision that recorded no artifact
costs contributes what it was quoted then, not what the same render
would cost now. That is the right answer for “what did this project
spend”; it is the wrong one for “what would this cost today”, and
[`nw.pricing.quote_render_decision()`](_autosummary/nw.pricing.html.md#nw.pricing.quote_render_decision) is what answers that (nw#74).

Walks **every store scope** (graph, storyboard, alignment), not just
the project graph: a decision written to the storyboard scope is money
that was spent, and counting only one scope would silently *under*-report
while `caveats` claims an upper bound.

**This is an upper bound on money usefully spent.** A render that was
billed and then failed is recorded exactly like one that succeeded,
because nothing in the execution layer records a per-branch outcome
yet. When failure isolation lands, this should sum over the *produced*
branches only — and this method is the one place that changes.

* **Return type:**
  [`float`](https://docs.python.org/3/builtins/functions.html#float)

#### update_spec(\*\*changes)

Apply field-level changes to the spec; return the new spec.

* **Return type:**
  [`ProjectSpec`](_autosummary/nw.schema.html.md#nw.schema.ProjectSpec)

#### write_spec(spec)

Write the spec.

For back-compat with existing code that builds a `ProjectSpec` and
calls `write_spec`, this routes graph-backed fields (sections,
shots, characters, environments) through the graph and persists the
rest as project.json metadata.

* **Return type:**
  [`None`](https://docs.python.org/3/builtins/constants.html#None)

### *class* nw.ProjectGraph(project_root)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

Typed read/write facade over the project’s lacing graph store.

Use `Project.graph()` to get one rather than constructing directly.
Each method opens-and-closes the underlying store so concurrent
reads/writes from different processes are safe (SqliteStore is
file-locked).

#### add_annotation(ann, , instance_id=None, call_index=None)

Write one annotation to the project graph, plus its verifying trace.

Registers `ann.tier` if it isn’t a known tier yet — `SqliteStore`
enforces a foreign key on `tier`, so writing under a fresh tier
(e.g. a Transform output kind) would otherwise fail. `add_tier` is
idempotent, so this is a no-op for the built-in project tiers.

This is the single choke point every *derived* annotation in nw and
reelee passes through, which is why the verifying trace
([`nw.bodies.verifying_trace`](_autosummary/nw.bodies.verifying_trace.html.md#module-nw.bodies.verifying_trace)) is recorded here rather than in
`derive_provenance`: that helper returns a `Provenance` and has
no store to write to, and threading one in would change a signature
with production callsites in three repos. Writing at persist time
also covers the paths that build a `Provenance` by hand.

Annotations with no `was_derived_from` parents get no trace — there
is nothing to verify, and they are nobody’s descendant.

It is also where a matching [`add_unproduced_output()`](_autosummary/nw.html.md#nw.ProjectGraph.add_unproduced_output) record is
retired (nw#44): a successful write is proof the thing that record
described has now been produced. `instance_id` / `call_index`
identify *this write’s* unit precisely (see
[`nw.bodies.unproduced_output`](_autosummary/nw.bodies.unproduced_output.html.md#module-nw.bodies.unproduced_output)’s module docstring for the key);
omit `instance_id` only when it is not known — the retirement then
falls back to `(transform_name, call_index, upstream)`, which still
cannot distinguish two DIFFERENT units sharing both an upstream set
and a `call_index` (the residual case the module docstring names),
so it may retire nothing, or (rarely) the wrong record.

\*\*Bypassing this method (a raw `store.add`) leaves a matching
unproduced-output record in place\*\* — it reads as a live blocker
after reload even though this write produced the thing it described.

* **Return type:**
  [`None`](https://docs.python.org/3/builtins/constants.html#None)

#### add_unproduced_output(skeleton, , transform_name, status, reason='', error=None, blocked_by=(), instance_id=None, call_index=None, was_attributed_to=None)

Persist why a planned output was never produced (nw#44).

Mirrors one entry of `TransformResult.failed` / `.blocked`
([`nw.transforms.FailedOutput`](_autosummary/nw.html.md#nw.FailedOutput)) — pass its `skeleton`,
`status`, `reason`, `error` and `blocked_by` straight through.
Written under `nw.bodies.UNPRODUCED_OUTPUT_BODY_SCHEMA_URI`,
never under `skeleton.body_schema_uri` (see the module’s docstring
on why that tier is reserved for what was actually produced).

`instance_id` (a fan-out unit’s
`work_item_instance_id()`) and
`call_index` (this output’s position within its `execute()`
call’s skeleton tuple) together form the identity
[`add_annotation()`](_autosummary/nw.html.md#nw.ProjectGraph.add_annotation) retires by — see
[`nw.bodies.unproduced_output`](_autosummary/nw.bodies.unproduced_output.html.md#module-nw.bodies.unproduced_output)’s module docstring for the full
key. **Dedupes on that identity**: a record already outstanding for
the same key is removed before this one is written, so a unit
failing twice leaves one current record, not two.

Parentless, like a verifying trace.

`reason` never stores raw exception text — see the module
docstring’s “reason never carries raw exception text”. When
`error` is given, the original `reason` is logged
(`logging.getLogger("nw.graph")`, `WARNING`) and the persisted
`reason` becomes a fixed sentence naming `error`’s type.

* **Return type:**
  [`UUID`](https://docs.python.org/3/library/uuid.html#uuid.UUID)

#### append_decision(body, , was_attributed_to='user:nw', was_derived_from=())

Append a decision; never replaces an existing one (the log is append-only).

* **Return type:**
  [`UUID`](https://docs.python.org/3/library/uuid.html#uuid.UUID)

#### genre_envelope()

The recorded genre envelope, or `None` for a genre-less project.

The read half of [`set_genre_envelope()`](_autosummary/nw.html.md#nw.ProjectGraph.set_genre_envelope); consumers should reach
it through [`nw.Project.resolved_genre()`](_autosummary/nw.html.md#nw.Project.resolved_genre), which returns the
plain-dict envelope shape `nw.genres.resolve_genre()` produces.

* **Return type:**
  [`Optional`](https://docs.python.org/3/library/typing.html#typing.Optional)[[`GenreEnvelopeBodyV1`](_autosummary/nw.bodies.genre_envelope.html.md#nw.bodies.genre_envelope.GenreEnvelopeBodyV1)]

#### remove_annotation(annotation_id)

Remove one annotation from the project graph, with its verifying traces.

The delete counterpart of [`add_annotation()`](_autosummary/nw.html.md#nw.ProjectGraph.add_annotation) (nw#36): every trace
whose `for_annotation_id` names `annotation_id` goes with it, so a
deletion never leaves a sidecar behind — an orphaned trace is inert
for freshness but grows the store without bound, and if the id is
later re-used it can even answer a freshness query from digests
recorded for content that is no longer there.

Only the project graph store is touched — the store
[`add_annotation()`](_autosummary/nw.html.md#nw.ProjectGraph.add_annotation) writes to. Annotations living in the other
scopes (storyboard, alignment) are removed by their own facades;
[`collect_orphan_traces()`](_autosummary/nw.html.md#nw.collect_orphan_traces) is the project-wide backstop.

* **Return type:**
  [`bool`](https://docs.python.org/3/builtins/functions.html#bool)
* **Returns:**
  Whether `annotation_id` itself was present (its traces are
  removed either way).

#### set_genre_envelope(body, , was_attributed_to='agent:nw.genres')

Record the resolved `{genre, template, params}` envelope; return its id.

Singleton per project (nw#32): the tier is the identity, so
re-initializing replaces the recorded envelope in place — the
annotation id is stable across replacements, like every entity
upsert. A no-op write (same envelope) writes nothing.

* **Return type:**
  [`UUID`](https://docs.python.org/3/library/uuid.html#uuid.UUID)

#### unproduced_outputs(, transform_name=None)

Every unproduced-output record still outstanding, oldest first.

A record disappears the moment [`add_annotation()`](_autosummary/nw.html.md#nw.ProjectGraph.add_annotation) writes a real
output for the same identity, or another [`add_unproduced_output()`](_autosummary/nw.html.md#nw.ProjectGraph.add_unproduced_output)
call for the same identity supersedes it (dedupe) — what this
returns is exactly “still missing”, survives a reload, and is not a
cache of any in-memory `TransformResult`.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`StoredUnproducedOutput`](_autosummary/nw.graph.html.md#nw.graph.StoredUnproducedOutput)]

#### upsert_character_ref(body, , was_attributed_to='user:nw')

Insert-or-update the character ref with this `name`; return its id.

The id is *stable* across edits — see `_upsert()`.

* **Return type:**
  [`UUID`](https://docs.python.org/3/library/uuid.html#uuid.UUID)

#### upsert_environment_ref(body, , was_attributed_to='user:nw')

Insert-or-update the environment ref with this `name`; return its id.

The id is *stable* across edits — see `_upsert()`.

* **Return type:**
  [`UUID`](https://docs.python.org/3/library/uuid.html#uuid.UUID)

#### upsert_section(body, interval, , was_attributed_to='user:nw')

Insert-or-update the section with this `section_id`; return its id.

The id is *stable* across edits — see `_upsert()`.

* **Return type:**
  [`UUID`](https://docs.python.org/3/library/uuid.html#uuid.UUID)

#### upsert_shot(body, interval, , was_attributed_to='user:nw')

Insert-or-update the shot with this `shot_id`; return its id.

The id is *stable* across edits — see `_upsert()`.

* **Return type:**
  [`UUID`](https://docs.python.org/3/library/uuid.html#uuid.UUID)

### *class* nw.ProjectSpec(\*\*data)

Bases: `BaseModel`

The top-level project SSOT, persisted as `project.json`.

Field names and order are chosen to round-trip identically with muvid’s
ProjectSpec for `schema_version=1`, so the_bells_v\* fixtures (and any
other muvid-shaped project) load and re-save without churn.

#### model_config *: [ClassVar](https://docs.python.org/3/library/typing.html#typing.ClassVar)[ConfigDict]* *= {'extra': 'ignore'}*

Configuration for the model, should be a dictionary conforming to [`ConfigDict`][pydantic.config.ConfigDict].

### *class* nw.ProjectSummary(\*\*data)

Bases: `BaseModel`

Typed read view of a project — what `muvid status` printed, but typed.

Returned by [`Project.read_summary()`](_autosummary/nw.html.md#nw.Project.read_summary). Holds the small facts the user
most often wants: title, root, song path, counts of characters / shots /
sections / output, plus a coarse “stages_done” list naming the lifecycle
stages that have been reached.

#### model_config *: [ClassVar](https://docs.python.org/3/library/typing.html#typing.ClassVar)[ConfigDict]* *= {'extra': 'ignore'}*

Configuration for the model, should be a dictionary conforming to [`ConfigDict`][pydantic.config.ConfigDict].

#### *property* stages_done *: [list](https://docs.python.org/3/builtins/stdtypes.html#list)[[str](https://docs.python.org/3/builtins/stdtypes.html#str)]*

Coarse stage list — what’s been reached, in lifecycle order.

### *class* nw.Resolver(\*args, \*\*kwargs)

Bases: [`Protocol`](https://docs.python.org/3/library/typing.html#typing.Protocol)

`resolve(email, project_id, artifact_id) -> Deliverable` — a genre’s half.

`artifact_id` may be a raw id OR a reference the genre accepts (see
[`parse_ref()`](_autosummary/nw.html.md#nw.parse_ref)); resolving both is the genre’s job, because only it knows
the ordering that gives `cut 4` its meaning.

Raises `KeyError` when nothing resolves (the host answers 404) and
`PermissionError` when it resolves but is not the caller’s (403). Never
let a server path escape in the message.

### *class* nw.ResumptionBrief(\*\*data)

Bases: `BaseModel`

A “where we left off” snapshot, returned by [`nw.Project.resumption_brief()`](_autosummary/nw.html.md#nw.Project.resumption_brief).

Pure data: no fal calls, no LLM, no network. reelee renders it as prose
and injects it as the first tool-result of a session.

\*\*The field names are chosen to be honest about what nw can currently
measure\*\*, because a confidently wrong number is worse than no number:

- `downstream_of_last_authored_change` is *not* “stale”. It is
  `nw.descendants_of` — pure provenance reachability, comparing no
  content and no timestamp — so this set includes everything already
  regenerated since the change. It is an **upper bound** on what needs
  attention, and it is named for what it measures.

  `nw.stale_after` is the narrower answer and it now cuts off early
  (nw#24), so switching this field to it would return a smaller and
  correct set. That is deliberately **not** done here: the field would
  then be named for the wrong measurement, and which of the two a
  resumption brief should show is nw#7’s call, not nw#24’s. Callers who
  want the exact set can call `nw.stale_after` with
  `last_authored_change_id`.
- The walk starts at the last **authored** change — the most recent
  annotation the user wrote (a shot, a section, a character or
  environment ref), never one a Transform derived. Walking from “the
  newest annotation” instead would be inverted: the newest node in a
  provenance graph is by construction a *leaf*, so its descendant set is
  empty in exactly the case the field exists for.
- `total_spend_usd` sums *every* recorded render decision across
  every store scope. Nothing records per-branch outcomes yet, so a render
  that failed after being billed is counted here exactly like one that
  succeeded. Also an upper bound.

`caveats` carries those qualifications as data — so a consumer
renders them next to the numbers instead of rediscovering them.

#### model_config *: [ClassVar](https://docs.python.org/3/library/typing.html#typing.ClassVar)[ConfigDict]* *= {'extra': 'ignore'}*

Configuration for the model, should be a dictionary conforming to [`ConfigDict`][pydantic.config.ConfigDict].

### *class* nw.Secrets(mapping=None, , \*\*named)

Bases: [`Mapping`](https://docs.python.org/3/library/collections.abc.html#collections.abc.Mapping)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]

A read-only `{provider_name: key}` mapping that never prints or persists.

Construct from a mapping, keywords, or both; `None`/empty values are
dropped (absent means “not supplied”), a non-`str` key or value is a
`TypeError` — a secret is text, and an int or a bytes object here is a
caller bug worth failing on.

#### with_(\*\*named)

A copy with `named` layered on top (a boundary adding a provider).

* **Return type:**
  [`Secrets`](_autosummary/nw.secrets.html.md#nw.secrets.Secrets)

### *class* nw.SectionSpec(\*\*data)

Bases: `BaseModel`

A non-overlapping span of the project’s master timeline.

`label` is free-form (“intro”, “verse”, “chorus”, “scene-1”, “act-2”,
…) so different apps (music-video, explainer, podcast-clip) can use
their own taxonomy.

#### model_config *: [ClassVar](https://docs.python.org/3/library/typing.html#typing.ClassVar)[ConfigDict]* *= {'extra': 'ignore'}*

Configuration for the model, should be a dictionary conforming to [`ConfigDict`][pydantic.config.ConfigDict].

### *class* nw.ShotPreparation(project_root, shot, shot_dir, audio_slice_path, audio_slice_url='', character_anchor_paths=<factory>, character_anchor_urls=<factory>, environment_anchor_path=None, environment_anchor_url='', lyric_lines=<factory>, storyboard_prompt='', global_style='')

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

Local-only inputs for rendering a single shot.

Building a ShotPreparation is a pure-filesystem operation: no fal calls
that bill, no network beyond fal-storage uploads (which are free). The
upload step happens here so the resulting URLs are stable and the cache
key derived from them is honest.

Multiple downstream consumers (the planner, an inspection report, a UI
preview) can read this without re-doing the audio extraction.

#### audio_slice_path *: [Path](https://docs.python.org/3/library/pathlib.html#pathlib.Path)*

Local path to the song’s audio over [shot.start_s, shot.end_s].

#### audio_slice_url *: [str](https://docs.python.org/3/builtins/stdtypes.html#str)*

fal-storage URL of the audio slice (set by [`prepare_shot()`](_autosummary/nw.html.md#nw.prepare_shot) when
a fal API key is available; empty otherwise — strategies that need URLs
will raise descriptively).

#### character_anchor_paths *: [dict](https://docs.python.org/3/builtins/stdtypes.html#dict)[[str](https://docs.python.org/3/builtins/stdtypes.html#str), [Path](https://docs.python.org/3/library/pathlib.html#pathlib.Path)]*

Per-character path to the curated anchor image.

#### character_anchor_urls *: [dict](https://docs.python.org/3/builtins/stdtypes.html#dict)[[str](https://docs.python.org/3/builtins/stdtypes.html#str), [str](https://docs.python.org/3/builtins/stdtypes.html#str)]*

Per-character fal-storage URL of the anchor image.

#### environment_anchor_path *: [Path](https://docs.python.org/3/library/pathlib.html#pathlib.Path) | [None](https://docs.python.org/3/builtins/constants.html#None)*

Path to the environment establishing image, or None.

#### environment_anchor_url *: [str](https://docs.python.org/3/builtins/stdtypes.html#str)*

fal-storage URL of the environment image; empty if no env image.

#### lyric_lines *: [list](https://docs.python.org/3/builtins/stdtypes.html#list)[[dict](https://docs.python.org/3/builtins/stdtypes.html#dict)]*

List of `{"text", "start_s", "end_s", "line_index", "section"}` dicts.

#### storyboard_prompt *: [str](https://docs.python.org/3/builtins/stdtypes.html#str)*

shot description + framing + camera + characters +
environment + style + lyric lines (when present).

* **Type:**
  Full prose prompt

### *class* nw.ShotReport(\*\*data)

Bases: `BaseModel`

Inspection of one rendered shot.

#### *property* has_long_freeze *: [bool](https://docs.python.org/3/builtins/functions.html#bool)*

Any freeze ≥ 1.0s is suspicious. Anything ≥ 0.5s is worth flagging.

#### model_config *: [ClassVar](https://docs.python.org/3/library/typing.html#typing.ClassVar)[ConfigDict]* *= {'frozen': True}*

Configuration for the model, should be a dictionary conforming to [`ConfigDict`][pydantic.config.ConfigDict].

### *class* nw.ShotSpec(\*\*data)

Bases: `BaseModel`

A timeline-locked visual unit.

`[start_s, end_s)` is half-open. `render_strategy` is an open string
rather than a closed Literal, so apps can register their own strategies
via [`nw.renderers.register_strategy()`](_autosummary/nw.renderers.html.md#nw.renderers.register_strategy) (Phase 1b.3) without modifying
the schema.

#### model_config *: [ClassVar](https://docs.python.org/3/library/typing.html#typing.ClassVar)[ConfigDict]* *= {'extra': 'ignore'}*

Configuration for the model, should be a dictionary conforming to [`ConfigDict`][pydantic.config.ConfigDict].

### *class* nw.SongInfo(\*\*data)

Bases: `BaseModel`

Metadata for the master audio file.

Compatible with muvid’s SongInfo by field name and type.

#### model_config *: [ClassVar](https://docs.python.org/3/library/typing.html#typing.ClassVar)[ConfigDict]* *= {'extra': 'ignore'}*

Configuration for the model, should be a dictionary conforming to [`ConfigDict`][pydantic.config.ConfigDict].

### *class* nw.StoredUnproducedOutput(annotation_id, body)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

### *class* nw.Strategy(\*args, \*\*kwargs)

Bases: [`Protocol`](https://docs.python.org/3/library/typing.html#typing.Protocol)

Render-strategy contract.

#### materialize(prep, plan, artifacts)

Turn executed Artifacts into `shot_dir/output.mp4`. May download +
run ffmpeg, but no fal calls.

* **Return type:**
  Path

#### plan(prep, , quality='balanced', model_overrides=None)

Build a `falaw.Plan` for the prepared shot. No fal calls.

* **Return type:**
  `Plan`

### *class* nw.Template(slug, title, description='', params=<factory>)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

A named preset (“subgenre”) *within* a genre — a filled-in default config.

AV-general: the substrate owns the Template’s identity (`slug`/`title`/
`description`) and carries an opaque `params` payload it does **not**
interpret. The app that owns the genre puts meaning in `params` (reelee:
`{"output_intent": ..., "flavor": ...}`; braidio: `{"format_id": ...}`)
and validates/resolves it. Frozen + hashable (`params` is excluded from
identity and normalized to an immutable mapping), so a Template can live in a
`Genre.templates` tuple without breaking the genre’s `__hash__`.

```pycon
>>> t = Template(slug="cinematic_clip", title="Cinematic clip",
...              params={"flavor": "fal.cinematic"})
>>> t.params["flavor"]
'fal.cinematic'
>>> t.to_dict()["params"]
{'flavor': 'fal.cinematic'}
```

#### to_dict()

A JSON-able catalog entry: `{slug, title, description, params}`.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### *class* nw.Transform(\*args, \*\*kwargs)

Bases: [`Protocol`](https://docs.python.org/3/library/typing.html#typing.Protocol)

A swappable, costed function from A-annotations to B-annotations.

Implementations usually subclass [`BaseTransform`](_autosummary/nw.html.md#nw.BaseTransform) rather than
satisfying this Protocol directly, but the Protocol is the contract the
registry and orchestrator depend on.

#### execute(project, plan, skeleton, , use_cache=True, force=False, on_failure='halt', unit_instance_id=None, secrets=None)

Run `plan`, complete `skeleton`, write to the graph, return result.

`force=True` bypasses the cache **read** (the “regenerate this”
affordance) and keeps the cache **write**, so a forced re-run stays
reusable instead of billing the next consumer again (nw#72).
`use_cache=False` means “do not touch the cache at all”; pairing it
with `force=True` raises [`CacheModeConflict`](_autosummary/nw.html.md#nw.CacheModeConflict).

`on_failure` selects the failure policy — see `OnFailure`.
`"halt"` is the default so no existing caller changes behaviour.

#### WARNING
`on_failure` is **newer than some implementations**. A Transform
that overrides [`execute()`](_autosummary/nw.html.md#nw.Transform.execute) and predates nw#25 does not accept the
keyword, and this Protocol is `runtime_checkable`, which compares
*method names* and not signatures — so `isinstance` still passes
and the `TypeError` arrives at call time. reelee has ~18 such
overrides (thorwhalen/reelee#299 tracks the migration).

Until they are migrated, a caller iterating over arbitrary registered
Transforms should pass `on_failure` only to ones it knows accept
it, or catch `TypeError`. Everything inheriting
[`BaseTransform`](_autosummary/nw.html.md#nw.BaseTransform)’s `execute` — the common case — already
does.

`unit_instance_id` (nw#44) is the same accepts-it-or-not shape,
newer still: `fan_out_execute()` passes a
fan-out unit’s own
`work_item_instance_id()` here, when
accepted, as the precise identity an unproduced-output record is
retired by. Not a Transform’s concern beyond forwarding it to
[`nw.graph.ProjectGraph.add_unproduced_output()`](_autosummary/nw.graph.html.md#nw.graph.ProjectGraph.add_unproduced_output) /
[`add_annotation()`](_autosummary/nw.graph.html.md#nw.graph.ProjectGraph.add_annotation) — [`BaseTransform`](_autosummary/nw.html.md#nw.BaseTransform)
already does.

`secrets` is the third keyword of that shape, and the one that
carries a **credential**: the caller’s per-call, bring-your-own API
key(s), as a read-only `{provider_name: key}` mapping
([`nw.Secrets`](_autosummary/nw.html.md#nw.Secrets); every entry point coerces a plain mapping to
it). It is the one input that is deliberately *not an input* — it
never enters the Plan, the skeleton, provenance, the cache identity,
a run record, the job index or a log line — and a Transform that
spends a caller’s credential reads it here and nowhere else.
`None` (the default, and what every call did before the seam
existed) means “resolve from the process environment”.
`fan_out_execute()` and
[`nw.jobs.enqueue()`](_autosummary/nw.jobs.html.md#nw.jobs.enqueue) pass it accepts-it-or-not, so an override
that has no key to spend never sees it; [`BaseTransform`](_autosummary/nw.html.md#nw.BaseTransform) binds
a [`FAL_SECRET`](_autosummary/nw.secrets.html.md#nw.secrets.FAL_SECRET) as the fal credential for the
duration of the call, and `fan_out_execute` binds it around every
unit whether or not the keyword is accepted. An override that
declares the keyword and is called *directly* receives whatever the
caller passed — run it through [`nw.secrets.as_secrets()`](_autosummary/nw.secrets.html.md#nw.secrets.as_secrets) before
logging or formatting it. See [`nw.secrets`](_autosummary/nw.secrets.html.md#module-nw.secrets).

* **Return type:**
  [`TransformResult`](_autosummary/nw.html.md#nw.TransformResult)

#### generate_when *: [str](https://docs.python.org/3/builtins/stdtypes.html#str)*

When this Transform’s fan-out cardinality is knowable (nw#26):
`"static"` (the work-item list is derivable before the run — a
pre-flight estimate is a real number) or `"dynamic"` (cardinality is
known only after an upstream call returns — the only honest pre-flight
estimate is *unknown*, which forces approval). Undeclared defaults to
`"dynamic"`: fail expensive-looking. See
`nw.transforms.fanout.GenerateWhen`.

#### impl_version *: [str](https://docs.python.org/3/builtins/stdtypes.html#str)*

Behaviour version of this implementation (nw#27).

“Same interface, changed behaviour” — a prompt-template edit, a
post-processing change — bumps this \*\*without renaming the registry
key\*\* (the name denotes the capability; a different capability gets a
different name). It is a lock, not a receipt: it enters provenance
(`transform:<name>@<impl_version>`) and, when it is not
`DFLT_IMPL_VERSION`, the falaw cache identity of every call
executed through `BaseTransform.execute()` — so a behaviour change
cannot keep serving results minted by the old behaviour. A Transform
that overrides `execute` must apply
[`stamp_transform_identity()`](_autosummary/nw.html.md#nw.stamp_transform_identity) itself; the lock only locks what
passes through it.

#### input_kinds *: [tuple](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[str](https://docs.python.org/3/builtins/stdtypes.html#str), ...]*

Body-schema URIs this Transform reads. The first is the *primary*
kind; the rest are context kinds.

#### is_batch *: [bool](https://docs.python.org/3/builtins/functions.html#bool)*

How [`plan()`](_autosummary/nw.html.md#nw.Transform.plan) consumes `inputs.primary`.

`False` (one-to-one): [`plan()`](_autosummary/nw.html.md#nw.Transform.plan) operates on a *single* primary
annotation (`inputs.primary[0]`) — e.g. `beat_to_panel`, one beat in,
one panel out. A caller wanting to apply it across many annotations calls
[`plan()`](_autosummary/nw.html.md#nw.Transform.plan) once per annotation and composes the Plans.

`True` (batch): [`plan()`](_autosummary/nw.html.md#nw.Transform.plan) consumes *all* of `inputs.primary` at
once — e.g. `extract_characters` (every beat → an LLM call) or
`clips_to_animatic` (every clip → one animatic). A caller passes the
whole set in a single [`plan()`](_autosummary/nw.html.md#nw.Transform.plan) call.

This is the property an orchestrator needs to fan a Transform across a
project’s annotations correctly — it can’t be inferred from
`input_kinds`.

#### name *: [str](https://docs.python.org/3/builtins/stdtypes.html#str)*

Globally-unique identifier in [`transforms`](_autosummary/nw.transforms.html.md#nw.transforms).

#### output_kind *: [str](https://docs.python.org/3/builtins/stdtypes.html#str)*

The body-schema URI this Transform produces.

#### params_model *: [type](https://docs.python.org/3/builtins/functions.html#type)*

Pydantic model class for this Transform’s per-call params;
`type(None)` means no params. On the Protocol — not just
[`BaseTransform`](_autosummary/nw.html.md#nw.BaseTransform) — so anything reading a Transform through the
contract (the capability catalogue, an MCP tool builder, the CLI
dispatcher) can rely on it (nw#27).

#### plan(project, inputs, , params=None)

Build a `falaw.Plan` + skeleton output annotations.

Pure data. No billable calls. The skeleton annotations have provenance
filled in; their bodies’ artifact references are placeholders that
[`execute()`](_autosummary/nw.html.md#nw.Transform.execute) replaces.

* **Return type:**
  [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[`Plan`, [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[`Annotation`, [`...`](https://docs.python.org/3/builtins/constants.html#Ellipsis)]]

### *class* nw.TransformInputs(primary, context=<factory>)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

The annotations a Transform consumes.

`primary` is the subject of the operation — a single-element tuple for
one-to-one Transforms, many for batch Transforms (e.g. `clips_to_animatic`
consumes every clip). `context` is side material keyed by kind name, so
a Transform that declares `input_kinds=(beat, character-ref)` receives
the Beat in `primary` and the CharacterRefs in `context["character-ref"]`.

### *class* nw.TransformResult(annotations, artifacts=(), cost_usd_actual=0.0, cache_hit_savings_usd=0.0, has_unknown_costs=False, failed=(), blocked=())

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

The outputs of a Transform’s [`execute()`](_autosummary/nw.html.md#nw.Transform.execute).

#### annotations *: [tuple](https://docs.python.org/3/builtins/stdtypes.html#tuple)[Annotation, ...]*

The completed output annotation(s), written to the project graph.

#### artifacts *: [tuple](https://docs.python.org/3/builtins/stdtypes.html#tuple)[Artifact, ...]*

The `lacing.Artifact`s produced (images, videos, audio, json …).
Annotations reference these by `artifact_id` in their bodies.

#### blocked *: [tuple](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[FailedOutput](_autosummary/nw.html.md#nw.FailedOutput), ...]*

Outputs never attempted because an upstream call failed.

#### cache_hit_savings_usd *: [float](https://docs.python.org/3/builtins/functions.html#float)*

USD not spent because a call was served from cache.

Also observed rather than predicted — this changed source at the same time
as `cost_usd_actual`, from `Plan.cache_hit_savings_usd` (what planning
guessed would hit) to what actually hit.

#### cost_usd_actual *: [float](https://docs.python.org/3/builtins/functions.html#float)*

USD billed during execution, over the calls that \*\*succeeded and were not
cache hits\*\* — falaw’s observed `ExecutionReport.estimated_spend_usd`.
Since falaw#26 the per-`Artifact` `cost_usd` is *also* stamped from the
observed outcome, so the two now agree; the report stays the source here
because it is the run-level truth (and carries `has_unknown_costs`),
not because the artifacts lie anymore.

**A lower bound, despite the name.** falaw runs its converter *inside* the
unit of work, after the billed call, so a call fal charged for can still end
as `status="failed"` — and a failed call is excluded here, because falaw
cannot know whether the vendor billed it and inventing a number would be
worse. Under `"halt"` the run aborted anyway; under `"isolate"` it
continues, so a caller accumulating this across a fan-out with failures will
under-count. Read `failed` alongside it.

#### failed *: [tuple](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[FailedOutput](_autosummary/nw.html.md#nw.FailedOutput), ...]*

Outputs whose own call failed. Empty unless `on_failure="isolate"`.

#### has_unknown_costs *: [bool](https://docs.python.org/3/builtins/functions.html#bool)*

Whether any executed call had no price. Carried so `$0.00` stays
distinguishable from “we do not know”, which is the distinction every cost
gate in the federation is required to read.

#### *property* is_complete *: [bool](https://docs.python.org/3/builtins/functions.html#bool)*

Whether every planned output was produced.

### *exception* nw.UnknownGenreOpError

Bases: [`KeyError`](https://docs.python.org/3/builtins/exceptions.html#KeyError)

`genre_op` was asked for a name the genre does not register.

A [`KeyError`](https://docs.python.org/3/builtins/exceptions.html#KeyError) so a caller that already catches unknown-key lookups keeps
working; its message names the known ops, because “no such op” with no menu is a
dead end for a model choosing among them.

### *class* nw.UnproducedOutputBodyV1(\*\*data)

Bases: `BaseModel`

Body of an unproduced-output record.

`status` mirrors `nw.transforms.fanout.UnitStatus`’s two
unproduced cases: `"failed"` (the call itself failed) or `"blocked"`
(an upstream call in the same plan failed first). `upstream` is stored
for the `call_index` fallback identity (see the module docstring); it
is not itself a sufficient key.

#### model_config *: [ClassVar](https://docs.python.org/3/library/typing.html#typing.ClassVar)[ConfigDict]* *= {'extra': 'forbid', 'frozen': True}*

Configuration for the model, should be a dictionary conforming to [`ConfigDict`][pydantic.config.ConfigDict].

### *exception* nw.ValidationError(report)

Bases: [`AssertionError`](https://docs.python.org/3/builtins/exceptions.html#AssertionError)

Raised by [`ValidationReport.raise_if_failed()`](_autosummary/nw.html.md#nw.ValidationReport.raise_if_failed).

### *class* nw.ValidationReport(target, results=(), elapsed_s=0.0)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

Everything a [`validate()`](_autosummary/nw.html.md#nw.validate) run produced.

#### target

what was validated.

#### results

one per check that was selected, in the order they were run.

#### elapsed_s

wall-clock for the whole run.

#### *property* ok *: [bool](https://docs.python.org/3/builtins/functions.html#bool)*

No failing findings **and** nothing that failed to run.

A check that errored is not a pass. Callers gating a publish on this
get the conservative answer without having to remember to ask for it.

#### raise_if_failed()

Return self, or raise [`ValidationError`](_autosummary/nw.html.md#nw.ValidationError) — for a hard gate.

* **Return type:**
  [`ValidationReport`](_autosummary/nw.validation.html.md#nw.validation.ValidationReport)

#### summary()

A few lines a human can read without unpacking the object.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

### *class* nw.WorkItem(\*\*data)

Bases: `BaseModel`

One unit of a fan-out — the PDG-shaped work item (nw#26).

`scope_interval` puts *time in the demand, not the graph* (Nuke’s
model): a pipeline that stores frame ranges in nodes must edit the graph
to change a range; one that stores them in the request does not. It is
an interval rather than a point because lacing’s `TimeInterval` admits
`start == end` as a valid point annotation — the point-demand case is
already representable, no second demand type needed.

#### *property* instance_id *: [UUID](https://docs.python.org/3/library/uuid.html#uuid.UUID)*

This item’s instance id is only defined *for a transform* — use
[`work_item_instance_id()`](_autosummary/nw.html.md#nw.work_item_instance_id). This property exists to raise a
helpful error instead of letting `item.instance_id` look like it
could mean something transform-free.

#### model_config *: [ClassVar](https://docs.python.org/3/library/typing.html#typing.ClassVar)[ConfigDict]* *= {'frozen': True}*

Configuration for the model, should be a dictionary conforming to [`ConfigDict`][pydantic.config.ConfigDict].

### nw.all_stale(project_root)

Every annotation that is currently stale, regardless of cause.

[`stale_verdicts_all()`](_autosummary/nw.html.md#nw.stale_verdicts_all) with the fresh verdicts dropped — the
snapshot counterpart of [`stale_after()`](_autosummary/nw.html.md#nw.stale_after), and the primitive a
freshness indicator or a “regenerate everything stale” verb should sit
on instead of re-deriving its own definition of the word.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[`Annotation`]

### nw.annotations_at_tier(project_root, tier)

Return every annotation at the given tier across all of the project’s stores.

Useful for reelee views that lens on a single annotation kind:
`annotations_at_tier(root, "shot")` returns every shot annotation
regardless of which store it lives in (project graph vs. storyboard
vs. alignment).

Asks each store for the tier rather than deserializing every annotation
and filtering. `by_tier` is a real indexed query on all four lacing
backends and was called by nothing in nw; this walked the whole project
to answer a question about one tier. Measured on 2000 annotations with
200 at the tier: **33.5 ms → 3.9 ms**, and the gap widens with project
size because one is O(all rows) and the other O(matching).

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[`Annotation`]

### nw.apply_to_projects(roots, fn, , parallel=False)

Apply `fn` to each project at `roots` and collect the results.

* **Parameters:**
  * **roots** ([`Iterable`](https://docs.python.org/3/library/typing.html#typing.Iterable)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str) | [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)]) – Iterable of project roots. Each must point to an existing
    nw project.
  * **fn** ([`Callable`](https://docs.python.org/3/library/typing.html#typing.Callable)[[[`Project`](_autosummary/nw.project.html.md#nw.project.Project)], [`TypeVar`](https://docs.python.org/3/library/typing.html#typing.TypeVar)(`T`)]) – Callable taking a [`Project`](_autosummary/nw.html.md#nw.Project) and returning anything. Use this
    for per-project operations: parsing a script, estimating cost,
    rendering, gathering reports.
  * **parallel** ([`bool`](https://docs.python.org/3/builtins/functions.html#bool)) – When True, run `fn` in a thread pool. Useful when `fn`
    is I/O- or API-bound (e.g. a render). When False (default), runs
    sequentially in submission order — the safest semantics.
* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`TypeVar`](https://docs.python.org/3/library/typing.html#typing.TypeVar)(`T`)]
* **Returns:**
  A list of `fn(project)` results in the same order as `roots`.

### Examples

```pycon
>>> # Estimate cost of all four sibling experiments without rendering:
>>> # totals = apply_to_projects(roots, lambda p: estimate_render_cost(p))
>>> # Apply the same script to all of them after a refactor:
>>> # apply_to_projects(roots, lambda p: parse_script(p))
```

### nw.as_secrets(secrets)

Coerce a caller-supplied mapping to [`Secrets`](_autosummary/nw.html.md#nw.Secrets); empty → `None`.

The nw entry points — `nw.BaseTransform.execute()`,
[`nw.fan_out_execute()`](_autosummary/nw.html.md#nw.fan_out_execute), [`nw.jobs.enqueue()`](_autosummary/nw.jobs.html.md#nw.jobs.enqueue) — run every incoming
`secrets` through this, so below *them* a Transform only ever sees the
redacting type. A Transform that **overrides** `execute` and is called
directly gets whatever the caller passed: an override that logs or
formats its `secrets` should `as_secrets` first (or the caller should
hand it a [`Secrets`](_autosummary/nw.html.md#nw.Secrets)), because a plain `dict` prints its values.

* **Return type:**
  [`Optional`](https://docs.python.org/3/library/typing.html#typing.Optional)[[`Secrets`](_autosummary/nw.secrets.html.md#nw.secrets.Secrets)]

```pycon
>>> as_secrets(None) is None
True
>>> as_secrets({"fal": None}) is None
True
>>> as_secrets({"fal": "k"})
Secrets(<1 redacted: fal>)
```

### nw.backfill_traces(project_root, , execute=False)

Bless a pre-trace project so the verifying-trace rule can read it (nw#58).

On a project whose annotations predate nw#24’s trace-writing, the
verifying-trace rule is a behavior change, not a wrapper: every derived
annotation reads stale (`no-trace`), and nothing heals them — regen
skips non-Transform-produced annotations, and body updates write no
trace. This writes, for each derived annotation with no trace, a trace
against its parents’ CURRENT content digests: blessing at-rest state as
fresh, which is exactly the old timestamp rule’s verdict for at-rest
data — semantics-preserving at the moment of migration.

**Report-only by default.** The first thing run against a real user’s
projects should be a read; pass `execute=True` to write. Idempotent
either way: an annotation that already has a usable trace is counted in
`already_traced` and never rewritten, so a partial run is simply
re-run rather than reasoned about. One caveat keeps “a read” honest at
the FILE level: stores are opened with `migrate=True`, so a store
stamped at an older lacing schema is upgraded ON OPEN even under the
default — run this only where the build that serves these stores is
already the new one (the D-vg-mcp-10 deploy ordering; a pre-migrated
file makes an old serving build refuse it).

**Not blessed, by design — the old rule’s own stale verdicts.** A parent
edited AFTER the annotation was derived is exactly the
pending-regeneration state the old timestamp rule reported stale;
blessing it would silently clear a real signal. Such annotations land in
`skipped` and stay no-trace-stale — same verdict, and a later regen
writes the true trace through the chokepoint. (Exact preservation in the
other direction is impossible — the trace rule recurses where the old
rule was one-hop — but that residual over-reports, the direction
[`nw.freshness`](_autosummary/nw.freshness.html.md#module-nw.freshness) documents as the safe one.)

What is deliberately NOT blessed, each with a `skipped` entry naming
the annotation and the reason:

- a parent that no longer exists — that annotation is genuinely
  `upstream-missing`, and a fabricated trace would hide a real hole;
- a parent list carrying artifact refs (64-hex asset ids) — the
  annotation-tier trace cannot cover them (nw#55), and a trace over a
  subset of the parents reads as stale anyway (“upstream set is not
  exactly `was_derived_from`”), so writing one would be decoration;
- a parent whose body cannot be digested — broken data at the producer,
  same rule as `build_verifying_trace()`.

Parentless annotations are never stale by contract, so they are counted
(`parentless`) and need nothing.

Returns one project’s report — callers migrating a tree of projects loop
and get per-project summaries for free:
`{"project", "stores_found", "examined", "backfilled", "already_traced",
"traced_unusable", "parentless",
"skipped": [{"annotation_id", "reason"}, ...], "executed"}`.
`backfilled` is the count of traces written when `execute=True`, and
of traces that WOULD be written otherwise; `executed` says which
reading applies. Read `stores_found` before trusting zeros: a typo’d
or empty root reports all-zero COUNTS, and `stores_found == 0` is what
distinguishes “nothing to migrate” from “not a project here”.
`traced_unusable` counts annotations whose existing trace the
freshness rule cannot use (foreign digest scheme, mismatched upstream
set) — permanently stale, deliberately not overwritten here; expect it
to be zero on genuine pre-trace projects. A broken project (corrupt
store, unreadable `project.json`) RAISES rather than reporting —
catch per root in a tree loop so one damaged project is recorded, not
silently averaged away.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### nw.can_place_genre_project(slug)

True iff `slug`’s registered factory accepts host **placement**.

The question a host asks *before* offering “create a project of this genre here”:
a genre whose factory predates `PLACEMENT_ARG` can still be created, but
only in its own app’s workspace — where the host cannot address it. False for an
unregistered genre.

* **Return type:**
  [`bool`](https://docs.python.org/3/builtins/functions.html#bool)

```pycon
>>> def _old(caller, project_id, *, title, template, params):
...     return {"project": None}
>>> def _new(caller, project_id, *, title, template, params, projects_dir=None):
...     return {"project": None}
>>> _ = register_genre_project_factory("_place_old", _old)
>>> _ = register_genre_project_factory("_place_new", _new)
>>> can_place_genre_project("_place_old"), can_place_genre_project("_place_new")
(False, True)
>>> can_place_genre_project("_place_nope")
False
>>> del genre_project_factories["_place_old"]
>>> del genre_project_factories["_place_new"]
```

### nw.clone_project(src_root, dst_root, , preserve=('song', 'lyrics', 'characters'), reset=('script', 'shots', 'output', '.nw'), title=None, force=False)

Clone an nw project to a new root.

* **Parameters:**
  * **src_root** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str) | [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)) – Path to an existing nw project (must contain `project.json`).
  * **dst_root** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str) | [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)) – Destination path. Must not exist (or pass `force=True` to
    overwrite).
  * **preserve** ([`Iterable`](https://docs.python.org/3/library/typing.html#typing.Iterable)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]) – Subtrees of `src_root` to copy verbatim into `dst_root`.
    Default: `("song", "lyrics", "characters")`.
  * **reset** ([`Iterable`](https://docs.python.org/3/library/typing.html#typing.Iterable)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]) – Subtrees of `dst_root` to (re)create as empty after copying.
    Default: `("script", "shots", "output", ".nw")`.
  * **title** ([`Optional`](https://docs.python.org/3/library/typing.html#typing.Optional)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]) – New title for the cloned project. Defaults to `dst_root`’s
    folder name.
  * **force** ([`bool`](https://docs.python.org/3/builtins/functions.html#bool)) – When True, overwrite an existing `dst_root` (refuses by default
    to avoid clobbering work).
* **Return type:**
  [`ProjectSummary`](_autosummary/nw.schema.html.md#nw.schema.ProjectSummary)
* **Returns:**
  [`ProjectSummary`](_autosummary/nw.html.md#nw.ProjectSummary) of the cloned project.

### nw.collect_orphan_traces(project_root)

Drop verifying traces whose target annotation no longer exists.

The backstop for deletion paths that do not (or cannot) go through
`remove_annotations_with_traces()` — a direct `store.remove`, an
external tool, history from before deletions collected traces (nw#36).
Walks every store under the project; a trace is an orphan when its
`for_annotation_id` resolves in **none** of them. Idempotent, and safe
to run as routine maintenance: an orphaned trace is never consulted by
[`nw.freshness`](_autosummary/nw.freshness.html.md#module-nw.freshness), so removing it changes no freshness answer.

A trace whose body cannot be read (not a dict, unparseable target id) is
left in place: it may be an orphan, but deleting what we cannot identify
is worse than carrying it.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`UUID`](https://docs.python.org/3/library/uuid.html#uuid.UUID)]
* **Returns:**
  The ids of the trace annotations removed, in store order.

### nw.compose_report(project, , freeze_sample_fps=4.0, duration_tolerance_s=0.1)

Per-shot reports + final-compose inspection in one call.

* **Return type:**
  [`ComposeReport`](_autosummary/nw.inspect.html.md#nw.inspect.ComposeReport)

### nw.cost_records(plan)

The JSON-able per-call cost rows nw persists in a decision payload.

A serialized call cannot be re-quoted from `application` and
`arguments` alone — the quantity hints that priced it are estimator-only
and never reach the wire arguments. Carrying `cost_basis` alongside the
frozen figure is what makes the row re-quotable later by
[`quote_from_cost_records()`](_autosummary/nw.html.md#nw.quote_from_cost_records).

`cost_basis` is omitted when unset, exactly as falaw omits it, so a row
written by a caller that records no basis is byte-identical to what nw
wrote before nw#74.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]]

```pycon
>>> from falaw import CallPlan, Plan
>>> call = CallPlan(tool="t", application="a", arguments={},
...                 output_kind="video", estimated_cost_usd=1.0)
>>> sorted(cost_records(Plan(calls=(call,)))[0])
['application', 'cache_status', 'estimated_cost_usd', 'tool']
```

### nw.create_genre_project(genre, caller, project_id, , title=None, template=None, projects_dir=None)

Create + seed a new project for a PLUGGED-IN `genre` in `caller`’s space.

The *create*-counterpart to [`resolve_genre()`](_autosummary/nw.html.md#nw.resolve_genre) (params) + [`initialize_genre()`](_autosummary/nw.html.md#nw.initialize_genre)
(seed): resolve → the genre’s factory (create in the caller’s space) → initialize.
**All-or-nothing** — if seeding fails the just-created project is rolled back (its
folder removed) and the error re-raised. Returns the factory’s JSON-able info (minus
the live `project`) plus the resolved `{genre, template, params}` envelope, so the
caller can immediately address the new project (e.g. by `project_id`). The same
envelope is **persisted on the project** by the initialize step (nw#32) — under the
all-or-nothing guarantee, since a failure there rolls the whole create back — so the
association survives this call returning; read it back via
[`nw.Project.resolved_genre()`](_autosummary/nw.html.md#nw.Project.resolved_genre).

`projects_dir` is the **placement**: the directory to create the project folder
in, so a host that will *serve* the project can put it where its own resolver
looks. `None` (the default) leaves placement to the genre’s app, which is the
pre-placement behaviour. Ask [`can_place_genre_project()`](_autosummary/nw.html.md#nw.can_place_genre_project) first, or handle the
`TypeError` a pre-placement factory raises here — the request is refused \*\*before
any filesystem effect\*\*, never quietly satisfied somewhere else.

Raises [`KeyError`](https://docs.python.org/3/builtins/exceptions.html#KeyError) on an unknown genre/template, or a genre with no registered
factory (a host’s own genre is created by the host, not via this path);
[`TypeError`](https://docs.python.org/3/builtins/exceptions.html#TypeError) when `projects_dir` is given for a factory that does not accept
it; [`RuntimeError`](https://docs.python.org/3/builtins/exceptions.html#RuntimeError) (after rolling the create back) when a factory accepted a
placement and did not honour it.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### nw.current_quote(plan, \*, pricers={'llm_rates': Pricer(quote=<function \_quote_from_llm_rates>, table='falaw/data/llm_rates.json', version=<functools._lru_cache_wrapper object>), 'model_catalogue': Pricer(quote = <function \_quote_from_catalogue>, table='falaw/data/models.json', version=<functools._lru_cache_wrapper object>)})

Re-quote `plan` at today’s rates and report the result honestly.

Pure data — `falaw.reprice_plan()` reads the committed rate tables and
does arithmetic. No network, no billing API, no cache peek, so this is
safe to call anywhere a `plan()` is (nw invariant #1).

* **Parameters:**
  * **plan** (`Plan`) – The plan to re-quote — typically one just rebuilt from a stored
    payload with [`plan_from_cost_records()`](_autosummary/nw.html.md#nw.plan_from_cost_records).
  * **pricers** ([`Mapping`](https://docs.python.org/3/library/typing.html#typing.Mapping)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), `Pricer`]) – Pricing rules by `falaw.CostBasis.pricer`. The seam for
    a caller with reconciled numbers of their own; see
    `falaw.reprice.Pricer`.
* **Return type:**
  [`PlanQuote`](_autosummary/nw.pricing.html.md#nw.pricing.PlanQuote)

```pycon
>>> from falaw import Plan
>>> current_quote(Plan(calls=())).status
'unchanged'
```

### nw.derived_from(project_root, annotation_id)

Return the annotations this one was directly derived from.

Walks `provenance.was_derived_from` *one hop only* across all of the
project’s stores.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[`Annotation`]

### nw.descendants_of(project_root, ancestor_id)

Return every annotation whose provenance chain leads back to `ancestor_id`.

Walks `provenance.was_derived_from` *transitively* across all of the
project’s lacing stores. This is the operation reelee’s freshness
analysis is built on (system overview §7): when a node changes, every
annotation in the closure of this set is “downstream of the change.”

Deterministic order: (generation time, id) — the same public ordering
contract as [`nw.freshness.stale_verdicts()`](_autosummary/nw.freshness.html.md#nw.freshness.stale_verdicts). The closure used to be
returned in set-iteration (hash-derived) order, which leaked into every
consumer’s output (nw#39).

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[`Annotation`]

### nw.describe_genre(slug)

One genre’s catalog entry (raises [`KeyError`](https://docs.python.org/3/builtins/exceptions.html#KeyError) if the slug is unknown).

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### nw.execute_render(prep, plan, , on_event=None, use_cache=True, project=None)

Execute a Plan, materialize the result as `shot_dir/output.mp4`.

Refuses to execute a plan-only Plan (one whose arguments still contain
`<plan-only:...>` placeholders) — those exist so the planner can show
cost without any uploads, and need to be replaced with real URLs (call
[`prepare_shot()`](_autosummary/nw.html.md#nw.prepare_shot) with `upload=True`) before execute.

* **Parameters:**
  * **prep** ([`ShotPreparation`](_autosummary/nw.workflow.html.md#nw.workflow.ShotPreparation)) – The [`ShotPreparation`](_autosummary/nw.html.md#nw.ShotPreparation) the Plan was built for.
  * **plan** (`Plan`) – A `falaw.Plan` (typically from [`plan_render_shot()`](_autosummary/nw.html.md#nw.plan_render_shot)).
  * **on_event** – Optional event subscriber forwarded to the falaw call layer.
  * **use_cache** ([`bool`](https://docs.python.org/3/builtins/functions.html#bool)) – When True (default), routes via `cached_call_fal` so
    cache hits skip the network.
  * **project** ([`Optional`](https://docs.python.org/3/library/typing.html#typing.Optional)[[`Project`](_autosummary/nw.project.html.md#nw.project.Project)]) – Optional [`Project`](_autosummary/nw.html.md#nw.Project). When given, a render-decision
    annotation is appended to the project graph after execution
    with `was_derived_from = (shot_annotation_id,)`, so reelee’s
    freshness queries (`descendants_of` / `stale_after`) walk
    from the shot to its render output.
* **Return type:**
  [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)
* **Returns:**
  Path to `shot_dir/output.mp4` (trimmed/padded to `prep.duration_s`).

### nw.execute_render_panel_images(project, storyboard, plan, panel_ids, , on_event=None, use_cache=True, on_failure='halt')

Execute `plan`, download each artifact, attach a PanelImage.

Returns a NEW `Storyboard` (input `storyboard` is unchanged) with
the materialized seed images attached as `role="seed"` PanelImages.

Files land under `<project_root>/storyboard/<panel_id>.png`. The
PanelImage record stores both the project-relative path and the
artifact_id (content hash via lacing.Artifact), so downstream consumers
can prefer one or the other.

`on_failure` is nw#25’s policy, and this is the function the issue names
as **nw’s real fan-out shape** — one `generate_image` per panel. Under
`"isolate"` a panel whose call failed is simply left without a seed image;
every panel that rendered keeps its own, instead of one content-filtered
panel discarding the whole batch. `"halt"` is the default and unchanged.

Panels are matched to outcomes **by index into the plan**, never by position
in a shortened artifact list — the latter attaches panel 48’s image to panel
47 the moment one call drops out.

* **Return type:**
  `Storyboard`

### nw.fan_out_execute(transform, project, fan_out, , use_cache=True, force=False, on_failure='isolate', secrets=None)

Execute a planned fan-out, one ordinary `transform.execute` per unit.

`on_failure` governs **both levels symmetrically**:

- within a unit, it is passed to the Transform’s `execute` (when the
  implementation accepts it — a pre-nw#25 override runs with its own
  halt-like behaviour inside the unit; cross-unit isolation still
  applies);
- across units, `"isolate"` (the default — it is the point of a
  fan-out) runs every unit and reports per-unit outcomes, while
  `"halt"` stops *submitting* units after the first raising unit and
  marks the rest `blocked`.

A unit whose `execute` **returns** is never a halt trigger, even when
its result is partial — the Transform already decided those failures
were survivable; only a raising unit halts.

Two protocol-violation shapes degrade rather than crash, deliberately:
an `execute` that rejects `use_cache`/`force` (or returns a
non-`TransformResult`) shows up as per-unit `failed` rows carrying
the `TypeError`/`AttributeError` — N identical rows for one
programming error reads worse than one loud raise, but the alternative
discards the run record for units that already spent. And a `**kwargs`
override that accepts-but-ignores `on_failure` runs its internal
default within the unit — undetectable by signature inspection in
principle; cross-unit policy is still honoured.

`use_cache` / `force` are forwarded per unit and mean what they mean
on [`Transform.execute()`](_autosummary/nw.html.md#nw.Transform.execute): `force` skips the cache **read** and keeps
the **write**, so re-forcing a 200-unit fan-out does not orphan 200 paid
results (nw#72). `use_cache=False, force=True` raises
`CacheModeConflict` **before the first unit runs**:
it is a contradiction decidable from the arguments alone, so it does not
get the degradation above — filing one programming error as N identical
failed rows is only the lesser evil for the shapes that cannot be checked
up front.

`secrets` — the caller’s per-call credentials ([`nw.Secrets`](_autosummary/nw.html.md#nw.Secrets);
any mapping is coerced) — reaches each unit two ways, so no registered
Transform can silently bill the server’s key. It is **passed** to
`execute` when the implementation declares the keyword (the same
accepts-it-or-not seam as `on_failure` and `unit_instance_id`), and it
is **bound** around every unit regardless — a `"fal"` secret is the fal
credential for the call ([`nw.secrets.using_secrets()`](_autosummary/nw.secrets.html.md#nw.secrets.using_secrets)) even for an
override that predates the seam. It reaches nothing else: not the units,
not the run record ([`FanOutResult.to_record()`](_autosummary/nw.html.md#nw.FanOutResult.to_record); a failing unit’s
`reason` is redacted), not a log line.

Units run **sequentially**. Concurrency *within* a unit is falaw’s
(`execute_plan_isolated` bounds it); concurrency *across* units is the
deferred-scheduler work nw#26 explicitly scopes out, and nothing here
forecloses it — units are planned independently and the result is
order-aligned, not order-dependent.

* **Return type:**
  FanOutResult

### nw.fan_out_plan(transform, project, items, , inputs_for, params=None)

Plan one Transform across `items` — each unit an ordinary `plan()` call.

`inputs_for` maps a work item to the [`TransformInputs`](_autosummary/nw.html.md#nw.TransformInputs)
its unit consumes; the item’s `attributes` carry any per-unit data it
needs to build them. No billable calls; pure data out.

Duplicate `mapping_key`s are refused: two units sharing a key share
an instance id, which destroys exactly the per-instance identity the key
exists to provide (retry, cost attribution, regenerate-just-this-one all
become ambiguous).

`stamp_transform_identity` is applied to each unit plan \*\*here, at plan
time\*\* — its own docstring asks orchestrators that hash or persist plans
before execution to do so, and a fan-out’s run record is such a
persistence. Idempotent, so `BaseTransform.execute()` re-stamping at
execute time changes nothing.

* **Return type:**
  FanOutPlan

### nw.format_ref(n)

The one spelling we print. Input is permissive; output never varies.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

```pycon
>>> format_ref(1), format_ref(42)
('cut 1', 'cut 42')
```

### nw.genre_catalog()

Every registered genre as a JSON-able catalog entry (sorted by slug).

This is the generic, app-agnostic catalog an HTTP route / MCP tool serves; see
[`Genre.to_dict()`](_autosummary/nw.html.md#nw.Genre.to_dict) for the entry shape.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)]

### nw.genre_op(genre_slug, name)

The op `name` of `genre_slug`; [`UnknownGenreOpError`](_autosummary/nw.html.md#nw.UnknownGenreOpError) naming the known.

* **Return type:**
  [`GenreOp`](_autosummary/nw.html.md#nw.GenreOp)

### nw.genre_ops(genre_slug)

The ops registered for `genre_slug`, in registration order (`()` if none).

* **Return type:**
  [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)

### nw.genre_ops_catalogue(genre_slug)

The pure-JSON catalogue of `genre_slug`’s ops (`[]` for a genre with none).

One dict per op — `name`, `title`, `description`, `effect`, `runs`,
`params_schema`, `host_params`, `max_upload_bytes`, `spends` — the shape a
host exports to a frontend’s codegen or an MCP
tool builder.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)

### nw.get_genre(slug)

Look up a genre by slug; raises [`KeyError`](https://docs.python.org/3/builtins/exceptions.html#KeyError) with the known slugs.

* **Return type:**
  [`Genre`](_autosummary/nw.html.md#nw.Genre)

### nw.get_strategy(name)

Look up a strategy by name; raises if unknown.

* **Return type:**
  [`Strategy`](_autosummary/nw.renderers.html.md#nw.renderers.Strategy)

### nw.get_transform(name)

Look up a Transform instance by name; raises with the known names.

* **Return type:**
  [`Transform`](_autosummary/nw.html.md#nw.Transform)

### nw.has_genre_project_factory(slug)

True iff a plugged-in project factory is registered for `slug`.

* **Return type:**
  [`bool`](https://docs.python.org/3/builtins/functions.html#bool)

### nw.initialize_genre(genre, project, , template=None, params=None)

Seed a freshly-created `project` for `genre` (+ optional `template`).

The side-effecting apply-counterpart to [`resolve_genre()`](_autosummary/nw.html.md#nw.resolve_genre). Dispatches to
the genre’s registered initializer ([`register_genre_initializer()`](_autosummary/nw.html.md#nw.register_genre_initializer)) when
one exists; **when none is registered this is a no-op** — the correct default
for a genre that seeds nothing on create (e.g. one whose preset is applied at
render time).

`params` is the resolved creation params (from [`resolve_genre()`](_autosummary/nw.html.md#nw.resolve_genre)); when
`None` it is resolved from the genre’s `template`/`defaults` here, so
`initialize_genre(genre, project)` seeds a project in the genre’s defaults in
one call. Raises [`KeyError`](https://docs.python.org/3/builtins/exceptions.html#KeyError) on an unknown genre or — **uniformly** — an
unknown `template` slug (matching [`resolve_genre()`](_autosummary/nw.html.md#nw.resolve_genre)).

**The envelope is persisted** (nw#32): after the initializer succeeds (or
no-ops), the resolved `{genre, template, params}` is recorded on the
project’s graph — so “what genre is this project?” stays answerable after
this call returns, on the host-creates path exactly as on
[`create_genre_project()`](_autosummary/nw.html.md#nw.create_genre_project)’s. Written *after* the seed on purpose: a
recorded envelope certifies a completed initialization, never a failed
one. A `project` stand-in without a `graph` (a test double, a
factory that returns no live project) skips the recording; read it back
via [`nw.Project.resolved_genre()`](_autosummary/nw.html.md#nw.Project.resolved_genre).

* **Return type:**
  [`None`](https://docs.python.org/3/builtins/constants.html#None)

```pycon
>>> _ = register_genre(Genre(slug="_noinit_demo", title="Demo"))
>>> initialize_genre("_noinit_demo", object())  # no initializer -> no seed
>>> del genres["_noinit_demo"]
```

### nw.is_migrated(project_root)

True iff this project has been migrated to the lacing graph.

* **Return type:**
  [`bool`](https://docs.python.org/3/builtins/functions.html#bool)

### nw.iter_all_annotations(project_root)

Walk every annotation in every store under a project (any backend).

* **Return type:**
  [`Iterator`](https://docs.python.org/3/library/typing.html#typing.Iterator)[`Annotation`]

### nw.list_genres()

Return all registered genre slugs (sorted).

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]

### nw.list_strategies()

Return all registered strategy names (sorted).

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]

### nw.list_transforms()

Return all registered Transform names (sorted).

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]

### nw.menu(, cost=None)

Every registered check, name-ordered — what a user chooses from.

* **Return type:**
  [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`Check`](_autosummary/nw.validation.html.md#nw.validation.Check), [`...`](https://docs.python.org/3/builtins/constants.html#Ellipsis)]

### nw.migrate_to_graph(project_root, , backup=True, was_attributed_to='agent:nw.migrate')

Migrate `project_root`’s project.json into the lacing graph.

Idempotent: returns `{"already_migrated": 1, ...}` with zero writes if
the sentinel exists. Otherwise reads `project.json`, writes equivalent
annotations into `project.annot.sqlite`, drops the migrated arrays from
`project.json`, and writes the sentinel.

* **Parameters:**
  * **project_root** ([`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)) – Path to a project root.
  * **backup** ([`bool`](https://docs.python.org/3/builtins/functions.html#bool)) – When True (default), copy the original `project.json` to
    `.nw/project.json.pre-graph.bak` before trimming.
  * **was_attributed_to** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str)) – Provenance for the migrator. Defaults to
    `"agent:nw.migrate"`; pass `"user:<handle>"` from a CLI.
* **Returns:**
  ```
  ``
  ```

  {“sections”: N, “shots”: N, “characters”: N, “environments”: N,
  : ”decisions”: N}\`\`.
* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`int`](https://docs.python.org/3/builtins/functions.html#int)]

### nw.open_project_stores(project_root)

Yield an iterator of open stores, one per scope, honouring the backend.

The backend-aware replacement for `for p in all_project_stores(...):
SqliteStore(p)`. Under SQLite it visits each existing per-scope file;
under Postgres it visits each scope’s tenant in the shared DB. Use it for
both reads (walk `.all()`) and writes (`.remove` / `.add`).

Each store is closed before the next opens, so consume each store’s
annotations before advancing.

* **Return type:**
  [`Iterator`](https://docs.python.org/3/library/typing.html#typing.Iterator)[[`Iterator`](https://docs.python.org/3/library/typing.html#typing.Iterator)[`IntervalAnnotationStore`]]

### nw.open_storyboard(project)

Load the project’s storyboard. Returns an empty one if not present.

* **Return type:**
  `Storyboard`

### nw.parse_ref(text)

The ordinal in a spoken reference, or `None` if it isn’t one.

`None` is the signal to fall through to treating the input as a raw
artifact id — which is why this never raises: “not an ordinal” is an
ordinary, expected answer, not an error.

* **Return type:**
  [`int`](https://docs.python.org/3/builtins/functions.html#int) | [`None`](https://docs.python.org/3/builtins/constants.html#None)

```pycon
>>> parse_ref("cut 4"), parse_ref("CUT4"), parse_ref(" cut - 4 ")
(4, 4, 4)
>>> parse_ref("#11"), parse_ref("11")
(11, 11)
>>> parse_ref("b02fc05417ea") is None, parse_ref("") is None
(True, True)
```

Zero and negatives are not references — deliverables are numbered from 1, so
accepting `cut 0` would resolve to a neighbour under a naive index:

```pycon
>>> parse_ref("cut 0") is None
True
```

### nw.plan_checks(selection)

Order the selection into waves that may each run concurrently.

Every check in a wave has all its requirements satisfied by earlier waves,
so the waves are the schedule: run each in turn, in parallel within it.
A check that is not `parallel_safe` gets a wave to itself.

* **Raises:**
  [**ValueError**](https://docs.python.org/3/builtins/exceptions.html#ValueError) – on a dependency cycle, or a requirement that is not
      registered — both at plan time, before anything has been spent.
* **Return type:**
  [*tuple*](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[*tuple*](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[*Check*](_autosummary/nw.validation.html.md#nw.validation.Check), …], …]

### Examples

```pycon
>>> plan_checks(())
()
```

* **Return type:**
  [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`Check`](_autosummary/nw.validation.html.md#nw.validation.Check), [`...`](https://docs.python.org/3/builtins/constants.html#Ellipsis)], [`...`](https://docs.python.org/3/builtins/constants.html#Ellipsis)]

### nw.plan_from_cost_records(records)

Rebuild a re-quotable `falaw.Plan` from [`cost_records()`](_autosummary/nw.html.md#nw.cost_records) rows.

The result is a **pricing** plan, not an executable one: `arguments` is
empty and `output_kind` is a placeholder, because a stored cost row does
not carry the wire payload and re-pricing does not read it. Never hand one
of these to `falaw.execute_plan()` — build a fresh plan for that.

Rows missing `cost_basis` come back basis-free, which is exactly what
makes them re-price as `"no_basis"` (unknown) rather than as their
frozen number.

* **Return type:**
  `Plan`

### nw.plan_render_panel_images(storyboard, , quality='balanced', image_size='landscape_16_9', model_id=None, only_missing=True)

Build a Plan that generates a seed image for each panel that lacks one.

* **Parameters:**
  * **storyboard** (`Storyboard`) – The `artful.Storyboard`.
  * **quality** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str)) – image-gen quality tier.
  * **image_size** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str)) – “landscape_16_9” by default; respects the storyboard’s
    aspect when it can be mapped to a falaw size, otherwise uses
    this default.
  * **model_id** ([`Optional`](https://docs.python.org/3/library/typing.html#typing.Optional)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]) – Override the image-gen model. Defaults to whatever
    `falaw.pick_model(category="image", quality_tier=quality)`
    picks (e.g. flux/dev at balanced).
  * **only_missing** ([`bool`](https://docs.python.org/3/builtins/functions.html#bool)) – When True (default), skip panels that already have a
    `role="seed"` image. When False, plan one call per panel
    regardless.
* **Return type:**
  [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[`Plan`, [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]]
* **Returns:**
  `(plan, panel_ids)` — the Plan, and the panel ids in the same
  order as the Plan’s calls (so [`execute_render_panel_images()`](_autosummary/nw.html.md#nw.execute_render_panel_images)
  knows which panel each artifact belongs to).

### nw.plan_render_shot(prep, , quality='balanced', model_overrides=None)

Build a `falaw.Plan` for rendering a prepared shot.

Dispatches on `prep.shot.render_strategy` via [`nw.renderers`](_autosummary/nw.renderers.html.md#module-nw.renderers).

* **Parameters:**
  * **prep** ([`ShotPreparation`](_autosummary/nw.workflow.html.md#nw.workflow.ShotPreparation)) – A [`ShotPreparation`](_autosummary/nw.html.md#nw.ShotPreparation) from [`prepare_shot()`](_autosummary/nw.html.md#nw.prepare_shot).
  * **quality** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str)) – Default quality tier passed to the strategy.
  * **model_overrides** ([`Optional`](https://docs.python.org/3/library/typing.html#typing.Optional)[[`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]]) – Optional mapping of strategy-step → model_id, e.g.
    `{"avatar": "fal-ai/bytedance/omnihuman/v1.5"}` to bypass the
    default avatar model. The keys understood by each strategy are
    documented on the strategy itself.
* **Return type:**
  `Plan`
* **Returns:**
  A `falaw.Plan`. Caller can inspect `plan.total_cost_usd` and
  decide whether to `execute_plan(plan)`.

### nw.prepare_shot(project, shot_id, , upload=True)

Resolve all local inputs for rendering a shot.

No billable fal calls. When `upload=True` (the default), local files
are uploaded to fal-storage so the planner can build a Plan with stable
URLs (uploads are free; the cache key derived from those URLs is honest).
When `upload=False` (e.g. for tests or dry-run reporting), the URL
fields are left empty.

Idempotent in spirit but not byte-stable: fal-storage URLs include
expiring signatures, so two `prepare_shot` calls on the same project
produce different URLs. The local file paths are byte-stable.

* **Parameters:**
  * **project** ([`Project`](_autosummary/nw.project.html.md#nw.project.Project)) – An [`nw.Project`](_autosummary/nw.html.md#nw.Project) instance.
  * **shot_id** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str)) – The shot’s id, as in `project.read_spec().shots[*].id`.
  * **upload** ([`bool`](https://docs.python.org/3/builtins/functions.html#bool)) – When True (default), upload local files to fal-storage and
    populate the `*_url` fields. When False, only the local paths
    are populated.
* **Return type:**
  [`ShotPreparation`](_autosummary/nw.workflow.html.md#nw.workflow.ShotPreparation)
* **Returns:**
  A [`ShotPreparation`](_autosummary/nw.html.md#nw.ShotPreparation) with local paths (and URLs if `upload`)
  ready to plan.

### nw.project_asset_id(project)

The asset_id used for storyboard panel references.

Uses the SHA-256 of the project’s song bytes when available, so the
asset_id matches whatever a downstream consumer would compute via
`lacing.hash_file()`. Falls back to a stable derived id when the
song isn’t available yet.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

### nw.quote_from_cost_records(records, \*, pricers={'llm_rates': Pricer(quote=<function \_quote_from_llm_rates>, table='falaw/data/llm_rates.json', version=<functools._lru_cache_wrapper object>), 'model_catalogue': Pricer(quote = <function \_quote_from_catalogue>, table='falaw/data/models.json', version=<functools._lru_cache_wrapper object>)})

Today’s price for the calls stored in a decision payload.

The read-back half of [`cost_records()`](_autosummary/nw.html.md#nw.cost_records). `None` or a non-sequence
(a payload that recorded no calls at all) yields an empty plan’s quote —
`total_usd == 0.0`, `status == "unchanged"` — because “no calls” is a
known zero, not an unknown.

* **Return type:**
  [`PlanQuote`](_autosummary/nw.pricing.html.md#nw.pricing.PlanQuote)

### nw.quote_render_decision(payload, \*, pricers={'llm_rates': Pricer(quote=<function \_quote_from_llm_rates>, table='falaw/data/llm_rates.json', version=<functools._lru_cache_wrapper object>), 'model_catalogue': Pricer(quote = <function \_quote_from_catalogue>, table='falaw/data/models.json', version=<functools._lru_cache_wrapper object>)})

Today’s price for a `render_shot` decision payload.

The counterpart to what `nw.workflow._record_render_decision()` wrote.
Read this — never `payload["total_estimated_cost_usd"]` — whenever a
stored render cost is about to be shown or gated on as a *current* figure.

A payload written before nw#74 carries no per-call basis, so it re-quotes
as `"unknown"` with `total_usd` `None`. That is the point: nobody
can say what it costs today, and saying so is better than repeating a
number that has since moved.

The stored `total_estimated_cost_usd` is the payload’s own headline, so
it — not the calls’ sum — is reported as [`PlanQuote.as_of_total_usd`](_autosummary/nw.html.md#nw.PlanQuote.as_of_total_usd).
When the two **disagree**, the whole payload is *unknown*: a total of $3
over a payload whose calls sum to $0 is a broken record, and answering
“$0, unchanged” would report a stored $3 as a known zero. nw’s own writer
never produces such a payload; a hand-edited or truncated one can, and
unknown is the only honest reading of it.

* **Return type:**
  [`PlanQuote`](_autosummary/nw.pricing.html.md#nw.pricing.PlanQuote)

```pycon
>>> broken = quote_render_decision(
...     {"calls": [], "total_estimated_cost_usd": 3.0})
>>> broken.status, broken.total_usd, broken.as_of_total_usd
('unknown', None, 3.0)
>>> stale = quote_render_decision(
...     {"calls": [{"tool": "t", "application": "a",
...                 "estimated_cost_usd": 3.0}],
...      "total_estimated_cost_usd": 3.0})
>>> stale.status, stale.total_usd, stale.as_of_total_usd
('unknown', None, 3.0)
```

### nw.recommend_genre(kind)

The slug of the genre whose `intake_kinds` contains `kind` (first in slug
order), or `None` when `kind` is falsy / unmatched.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str) | [`None`](https://docs.python.org/3/builtins/constants.html#None)

```pycon
>>> g = register_genre(Genre(slug="_rec_demo", title="Rec", intake_kinds=("essay",)))
>>> recommend_genre("essay")
'_rec_demo'
>>> recommend_genre("nope") is None and recommend_genre(None) is None
True
>>> del genres["_rec_demo"]
```

### nw.redact(text, secrets)

`text` with every secret value replaced by `<redacted:name>`.

For the places nw persists free text it did not author — an exception
message, a failure reason — while holding the values that must not land
there. Cheap, exact-substring, and a no-op with no secrets.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

```pycon
>>> redact("boom: key sk-1 rejected", {"fal": "sk-1"})
'boom: key <redacted:fal> rejected'
>>> redact("nothing here", None)
'nothing here'
```

### nw.redact_exception(error, secrets)

The exception to re-raise so that nothing it *renders* carries a secret.

Scrubs `args` and `__notes__` in place and, when `str(error)` is
still not clean — an exception whose message is built from a non-string
arg (`RuntimeError({"detail": key})`, `OSError(2, msg, path)`) or a
custom `__str__` — rebuilds it as `type(error)(scrubbed_text)`, falling
back to `RedactedError` when the type will not construct that way
or still renders the secret. The cause/context chain is scrubbed the same
way. Returns the object to raise: the original when it was already clean.

Applied where nw lets an exception escape toward a store it does not own
(the job worker: au persists the rendered text) or files it into a record
it does (a fan-out unit’s `reason`).

* **Return type:**
  [`BaseException`](https://docs.python.org/3/builtins/exceptions.html#BaseException)

### nw.register_check(check=None, \*\*kwargs)

Add a check to the menu, as a call or as a decorator.

As a call:

```default
register_check(Check(name="video.duration", summary="...", run=...))
```

As a decorator on the run function, with the rest as keywords:

```default
@register_check(name="video.duration", summary="...", cost="cheap")
def _duration(target, ctx): ...
```

### nw.register_genre(genre)

Register a [`Genre`](_autosummary/nw.html.md#nw.Genre) under its `slug`; returns it for inline use.

* **Return type:**
  [`Genre`](_autosummary/nw.html.md#nw.Genre)

```pycon
>>> g = register_genre(Genre(slug="doctest_demo", title="Demo"))
>>> get_genre("doctest_demo").title
'Demo'
>>> "doctest_demo" in list_genres()
True
>>> del genres["doctest_demo"]  # keep the shared registry clean
```

### nw.register_genre_initializer(slug, initializer)

Register an initializer for a genre slug; returns it for inline use.

Called by the genre’s owning app. `initializer(genre, template, project,
params) -> None` seeds a freshly-created project for the chosen genre/template
(see `GenreInitializer` for the side-effect contract);
[`initialize_genre()`](_autosummary/nw.html.md#nw.initialize_genre) dispatches to it. Independent of genre \*registration
order\* (keyed by the slug string). A genre that seeds nothing on create needs
no initializer at all.

* **Return type:**
  [`Callable`](https://docs.python.org/3/library/typing.html#typing.Callable)[[[`Genre`](_autosummary/nw.html.md#nw.Genre), [`Optional`](https://docs.python.org/3/library/typing.html#typing.Optional)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)], [`Project`](_autosummary/nw.project.html.md#nw.project.Project), [`Mapping`](https://docs.python.org/3/library/collections.abc.html#collections.abc.Mapping)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]], [`None`](https://docs.python.org/3/builtins/constants.html#None)]

```pycon
>>> _ = register_genre(Genre(slug="_init_demo", title="Demo",
...                          defaults={"look": "plain"}))
>>> seen = {}
>>> def _seed(genre, template, project, params):
...     seen["applied"] = (genre.slug, template, params)
>>> _ = register_genre_initializer("_init_demo", _seed)
>>> initialize_genre("_init_demo", object())  # params default to the genre's
>>> seen["applied"]
('_init_demo', None, {'look': 'plain'})
>>> del genres["_init_demo"]; del genre_initializers["_init_demo"]
```

### nw.register_genre_ops(genre_slug, ops)

Register the operations a genre offers on its projects; returns them as a tuple.

Called once by the genre’s **owning app**, beside its project factory, so a host can
serve the genre’s project operations via [`genre_ops()`](_autosummary/nw.html.md#nw.genre_ops) without importing the
genre’s package. Names must be unique within the genre. Registering the same genre
twice raises (the registry refuses conflicts, as every nw genre registry does).

* **Return type:**
  [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)

```pycon
>>> def _peek(project) -> dict:
...     '''Say hello.'''
...     return {"hello": True}
>>> _ = register_genre_ops("_ops_demo", [GenreOp("peek", _peek, title="Peek",
...                                              effect="read")])
>>> [op.name for op in genre_ops("_ops_demo")]
['peek']
>>> genre_op("_ops_demo", "peek").run(None)
{'hello': True}
>>> genre_ops("_nobody")
()
>>> del genre_ops_registry["_ops_demo"]
```

### nw.register_genre_project_factory(slug, factory)

Register a project factory for a genre slug; returns it for inline use.

Called by the genre’s **owning app** so a host that aggregates the genre can create
its projects via [`create_genre_project()`](_autosummary/nw.html.md#nw.create_genre_project) without knowing its storage. See
`GenreProjectFactory` for the signature + the caller-space contract.

* **Return type:**
  [`Callable`](https://docs.python.org/3/library/typing.html#typing.Callable)[[`...`](https://docs.python.org/3/builtins/constants.html#Ellipsis), [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)]

```pycon
>>> _ = register_genre(Genre(slug="_pf_demo", title="Demo",
...                          defaults={"format_id": "solo"}))
>>> made = {}
>>> def _f(caller, project_id, *, title, template, params):
...     made.update(caller=caller, project_id=project_id, params=params)
...     return {"project": None, "project_id": project_id}
>>> _ = register_genre_project_factory("_pf_demo", _f)
>>> create_genre_project("_pf_demo", "u@x.com", "p1")["project_id"]
'p1'
>>> (made["caller"], made["params"])
('u@x.com', {'format_id': 'solo'})
>>> del genres["_pf_demo"]; del genre_project_factories["_pf_demo"]
```

### nw.register_genre_resolver(slug, resolver)

Register a resolver for a genre slug; returns it for inline use.

Called by the genre’s owning app. `resolver(genre, template) -> params` maps a
chosen template (or `None` for “start from scratch”) to that app’s bare params
payload; [`resolve_genre()`](_autosummary/nw.html.md#nw.resolve_genre) adds the `{genre, template, params}` envelope.
Independent of genre *registration order* (keyed by the slug string).

* **Return type:**
  [`Callable`](https://docs.python.org/3/library/typing.html#typing.Callable)[[[`Genre`](_autosummary/nw.html.md#nw.Genre), [`Optional`](https://docs.python.org/3/library/typing.html#typing.Optional)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]], [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)]

```pycon
>>> _ = register_genre(Genre(slug="_resolver_demo", title="Demo",
...                          defaults={"look": "plain"}))
>>> _ = register_genre_resolver("_resolver_demo",
...     lambda genre, template: {"look": genre.defaults["look"], "via": "resolver"})
>>> resolve_genre("_resolver_demo")
{'genre': '_resolver_demo', 'template': None, 'params': {'look': 'plain', 'via': 'resolver'}}
>>> del genres["_resolver_demo"]; del genre_resolvers["_resolver_demo"]
```

### nw.register_strategy(name, impl)

Register a strategy. Returns `impl` so it can be used inline.

* **Return type:**
  [`Strategy`](_autosummary/nw.renderers.html.md#nw.renderers.Strategy)

### nw.register_transform(name, impl=None, , tags=())

Register a Transform under `name`. Two forms:

Direct — pass an instance:

```default
register_transform("clips_to_animatic.ffmpeg", ClipsToAnimatic())
```

Decorator — decorate a class; it is instantiated and the *instance* is
registered (so [`get_transform()`](_autosummary/nw.html.md#nw.get_transform) always returns something callable),
and the class is returned unchanged:

```default
@register_transform("beat_to_panel.llm.default")
class BeatToPanelLLM(BaseTransform):
    ...
```

Registration validates the contract the registry’s consumers depend on:
an empty `output_kind` is refused loudly, in the same spirit as the
registry’s `on_conflict="error"` — an agent’s unit of work must have a
declared output type, or “the job runs successfully but produces
nothing retrievable” becomes invisible to every layer that reports
success (nw#27).

`tags` is passed straight through to `xdol.Registry.register()`
(`transforms.keys_with_tag(tag)` / `transforms.search(tags=...)`
read it back). The field exists so a licence, a capability class, or a
cost class has somewhere to live *before* the registry opens to
third-party registrants — nw#29 stays closed to third parties for now
(see that issue and `misc/docs/Transform Registry — third-party
extension.md`); this is the one piece of that decision worth doing
regardless of when, or whether, the registry opens.

* **Return type:**
  `Union`[[`Transform`](_autosummary/nw.html.md#nw.Transform), [`Callable`](https://docs.python.org/3/library/typing.html#typing.Callable)[[[`type`](https://docs.python.org/3/builtins/functions.html#type)], [`type`](https://docs.python.org/3/builtins/functions.html#type)]]

### nw.resolve_defaults(genre, template=None)

Resolve a genre (+ optional template) to the params for a new project.

Returns `{"genre": slug, "template": template_or_None, "params": {...}}` — the
chosen [`Template`](_autosummary/nw.html.md#nw.Template)’s `params` when `template` is given, else the genre’s
`defaults`. Raises [`KeyError`](https://docs.python.org/3/builtins/exceptions.html#KeyError) on an unknown genre or template. The caller
(app) interprets `params` (reelee reads `output_intent`/`flavor`; braidio a
`format_id`).

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### nw.resolve_genre(genre, template=None)

Resolve a genre (+ optional template) to the standard creation envelope.

Always returns `{"genre": slug, "template": template_or_None, "params": {...}}` —
ONE stable contract for every host, regardless of whether the genre has a resolver.
`params` comes from the genre’s registered resolver
([`register_genre_resolver()`](_autosummary/nw.html.md#nw.register_genre_resolver)) when one exists, else from the generic
[`resolve_defaults()`](_autosummary/nw.html.md#nw.resolve_defaults) (the template’s params, or the genre’s `defaults`).

Raises [`KeyError`](https://docs.python.org/3/builtins/exceptions.html#KeyError) on an unknown genre or — **uniformly, resolver or not** —
an unknown `template` slug (the substrate owns template identity; a resolver only
interprets params, it doesn’t get to invent template slugs).

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

```pycon
>>> _ = register_genre(Genre(slug="_rg_demo", title="Demo",
...                          defaults={"flavor": "cinematic"}))
>>> resolve_genre("_rg_demo")  # no resolver registered -> generic params
{'genre': '_rg_demo', 'template': None, 'params': {'flavor': 'cinematic'}}
>>> del genres["_rg_demo"]
```

### nw.save_storyboard(project, storyboard, , panel_intervals, was_attributed_to='user:nw', was_generated_by='agent:nw.storyboard')

Persist a Storyboard into the project’s SqliteStore.

Wipes the existing storyboard panels (under the default tier) so the
save is idempotent — re-running with edited panels replaces them rather
than accumulating duplicates.

* **Return type:**
  [`None`](https://docs.python.org/3/builtins/constants.html#None)

### nw.shot_report(project, shot_id, , freeze_sample_fps=4.0, duration_tolerance_s=0.1)

Inspect `shots/<shot_id>/output.mp4` and return a typed report.

* **Parameters:**
  * **project** ([`Project`](_autosummary/nw.project.html.md#nw.project.Project)) – The [`nw.Project`](_autosummary/nw.html.md#nw.Project).
  * **shot_id** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str)) – The shot id.
  * **freeze_sample_fps** ([`float`](https://docs.python.org/3/builtins/functions.html#float)) – How many frames per second to extract for the
    freeze detector (default 4 fps; a freeze must hold across at
    least two consecutive samples to count).
  * **duration_tolerance_s** ([`float`](https://docs.python.org/3/builtins/functions.html#float)) – Acceptable difference between actual and
    target duration before flagging.
* **Return type:**
  [`ShotReport`](_autosummary/nw.inspect.html.md#nw.inspect.ShotReport)
* **Returns:**
  A [`ShotReport`](_autosummary/nw.html.md#nw.ShotReport).

### nw.stale_after(project_root, changed_id)

Return every annotation that `changed_id` actually invalidated.

The freshness operation. `changed_id`’s descendants are walked and each
is checked against the upstream value digests it recorded when it was
written ([`nw.bodies.verifying_trace`](_autosummary/nw.bodies.verifying_trace.html.md#module-nw.bodies.verifying_trace)). A descendant whose recorded
inputs still match the current ones is **not** returned — that is the
early cutoff, and it is why this is not `descendants_of` under another
name. The full rule, and the four things it deliberately does not catch,
are in this module’s docstring.

The returned list does NOT include `changed_id` itself (it is the source
of the change, not a stale derivative).

`descendants_of` is unchanged and still answers the reachability
question — “what is downstream of this?” is legitimate and the two verbs
are no longer synonyms. Use [`stale_verdicts()`](_autosummary/nw.html.md#nw.stale_verdicts) when you need the
*reason* a given annotation is in (or out of) this set.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[`Annotation`]

### nw.stale_verdicts(project_root, changed_id)

Classify every annotation downstream of `changed_id`.

The explained form of [`stale_after()`](_autosummary/nw.html.md#nw.stale_after): one verdict per reachable
annotation, stale or not, in a deterministic order (generation time, then
id). `changed_id` itself is never included — it is the source of the
change, not a derivative of it.

Use this when the *number* is being questioned. `stale_after` is the
same walk with the fresh verdicts dropped;
[`stale_verdicts_all()`](_autosummary/nw.html.md#nw.stale_verdicts_all) is the same classification with no
`changed_id` — the whole-project snapshot.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`FreshnessVerdict`](_autosummary/nw.freshness.html.md#nw.freshness.FreshnessVerdict)]

### nw.stale_verdicts_all(project_root)

Classify every derived annotation in the project — the snapshot form.

The question a freshness *indicator* asks: “what is stale in this
project right now?”, with no `changed_id` to anchor on. Same
verifying-trace classification as [`stale_verdicts()`](_autosummary/nw.html.md#nw.stale_verdicts), over a wider
frontier: every annotation with at least one provenance parent.

Two boundaries that are the point of this living here rather than each
consumer approximating it (nw#39):

- **Parentless annotations are never stale** and stay out of the walk —
  an imported screenplay must not read as stale forever. (nw-written
  verifying traces are parentless, so they stay out too.)
- **Upstream-stale recursion runs over the whole derived set.** The
  scoped walk only recurses into parents inside `reachable` (outside
  it a parent is by construction unaffected by the change); with no
  change there is no such boundary. The cycle guard covers termination.

**Legacy projects read all-stale, by design.** A derived annotation
written before verifying traces existed classifies `no-trace` →
stale, and unlike the scoped walk (which only surfaces it downstream
of an actual change) the snapshot reports it *always*, until it is
rewritten through the trace-writing path. A consumer replacing its own
weaker snapshot with this one is making a behavior change on pre-trace
projects, not installing a pure wrapper.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`FreshnessVerdict`](_autosummary/nw.freshness.html.md#nw.freshness.FreshnessVerdict)]

### nw.stamp_transform_identity(plan, transform)

Fold `transform.impl_version` into every call’s cache identity.

The reader that makes `impl_version` a lock instead of a receipt
(nw#27): a bumped version lands in each call’s falaw `key_extra`, so
a cached result minted by the old behaviour cannot be reused. At
`DFLT_IMPL_VERSION` nothing is stamped — every key ever issued
stays byte-identical, and the first real bump is the first salt.

Each stamped call’s `cache_status` is reset to `"unknown"`: the
plan-time peek keyed without the salt, so its prediction (typically
“hit” — the old-behaviour result is cached, invalidating it is the
point) would make cost gates quote $0.00 for a full re-bill.

`BaseTransform.execute()` applies this automatically. \*\*A Transform
that overrides\*\* `execute()` \*\*must apply it
itself\*\* — the lock only locks calls that pass through it (a
registry-wide conformance test is the honest guard). An orchestrator
that hashes or caches plans *before* execution (e.g. a job idempotency
key over `falaw.plan_hash`) should apply it at plan time so those
keys see the version too. Idempotent — stamping twice writes the same
value.

* **Return type:**
  `Plan`

### nw.storyboard_db_path(project)

Return the path to the project’s storyboard SQLite store.

* **Return type:**
  [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)

### nw.storyboard_from_shots(project, , title=None, style=None)

Build a one-panel-per-shot draft Storyboard from a project’s shots.

Each panel’s caption defaults to the shot’s description, framing and
camera carry over, and the panel’s `shot_id` points back at the shot.
No images are attached yet — use [`plan_render_panel_images()`](_autosummary/nw.html.md#nw.plan_render_panel_images) to
generate them.

Returns `(storyboard, panel_intervals)` so the caller can feed both
into [`save_storyboard()`](_autosummary/nw.html.md#nw.save_storyboard).

* **Return type:**
  [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[`Storyboard`, [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), `TimeInterval`]]

### nw.suggest(request, , include_paid=False)

Checks whose `example_requests` look like what the user just asked for.

Deliberately crude — a word-overlap score, not a model call — because this
runs on every request and its job is to narrow forty items to a handful
that a human or a model then confirms. `"paid"` checks are never
suggested: money is asked for by name.

* **Return type:**
  [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`Check`](_autosummary/nw.validation.html.md#nw.validation.Check), [`...`](https://docs.python.org/3/builtins/constants.html#Ellipsis)]

### Examples

```pycon
>>> suggest("")
()
```

### nw.summarize_all(roots)

Convenience: return a [`ProjectSummary`](_autosummary/nw.html.md#nw.ProjectSummary) for each project.

Equivalent to `apply_to_projects(roots, lambda p: p.read_summary())`.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`ProjectSummary`](_autosummary/nw.schema.html.md#nw.schema.ProjectSummary)]

### nw.transform_catalog()

Every registered Transform as a JSON-able capability entry (sorted by name).

The typed-capability surface an HTTP route / MCP tool builder / agent
serves or selects from, mirroring [`nw.genre_catalog()`](_autosummary/nw.html.md#nw.genre_catalog) (nw#28): a
consumer needs no registry-internal knowledge to render or compose.
Entry shape:

```default
{name, input_kinds, output_kind, is_batch, generate_when,
 impl_version, params_schema}
```

`name` is the registry key (the addressable name). `params_schema`
is the params model’s JSON Schema — `{}` for a Transform with no
params — and is what an MCP tool definition is built from. The whole
list is JSON-serializable as returned.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)]

### nw.unquotable(reason)

A quote for something that could not be re-quoted at all.

For the caller who was handed a *plan-shaped* thing that turned out not to
be a plan — an unparseable payload, an object of the wrong type. The honest
answer is `None` (unknown), not the frozen figure that came with it, and
not `0.0`.

[`PlanQuote.repriced`](_autosummary/nw.html.md#nw.PlanQuote.repriced) is an empty `falaw.RepricedPlan`: there
were no calls to diff. Read [`PlanQuote.reason`](_autosummary/nw.html.md#nw.PlanQuote.reason) for what went wrong.

* **Return type:**
  [`PlanQuote`](_autosummary/nw.pricing.html.md#nw.pricing.PlanQuote)

```pycon
>>> q = unquotable("params['plan'] is not a falaw Plan")
>>> q.status, q.total_usd, q.has_unknown_costs
('unknown', None, True)
```

### nw.using_secrets(secrets)

Bind the secrets nw itself knows how to use, for the duration of a block.

Today that is `FAL_SECRET`: when present it becomes the fal
credential (`falaw.using_fal_credentials()`) so every `call_fal`
inside the block authenticates with the caller’s key instead of the
server’s `FAL_KEY`. Anything else in `secrets` is left for the
Transform that declared it. With no fal secret this is a `nullcontext`,
so the `with` shape stays uniform.

* **Return type:**
  [`AbstractContextManager`](https://docs.python.org/3/library/contextlib.html#contextlib.AbstractContextManager)[[`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]

### nw.validate(target, , checks=(), max_workers=4, on_error='report')

Run `checks` against `target` and report.

* **Parameters:**
  * **target** ([`Any`](https://docs.python.org/3/library/typing.html#typing.Any)) – whatever the checks understand — a path to a rendered file, a
    `Project`, a `(video, annotations)` pair. This module does not
    care; it is the checks that agree with their caller.
  * **checks** ([`Iterable`](https://docs.python.org/3/library/typing.html#typing.Iterable)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str) | [`Check`](_autosummary/nw.validation.html.md#nw.validation.Check)]) – names or [`Check`](_autosummary/nw.html.md#nw.Check) objects. Requirements are pulled in
    automatically. Empty means empty: validation is placed, never
    assumed.
  * **max_workers** ([`int`](https://docs.python.org/3/builtins/functions.html#int)) – concurrency within a wave.
  * **on_error** ([`Literal`](https://docs.python.org/3/library/typing.html#typing.Literal)[`'report'`, `'raise'`]) – `"report"` records a raising check as an errored
    [`CheckResult`](_autosummary/nw.html.md#nw.CheckResult) and carries on, so one broken plugin cannot
    hide the findings of the other nine. `"raise"` is for developing
    a check.
* **Return type:**
  [`ValidationReport`](_autosummary/nw.validation.html.md#nw.validation.ValidationReport)
* **Returns:**
  A [`ValidationReport`](_autosummary/nw.html.md#nw.ValidationReport). Note that `report.ok` is `False` when
  a check *errored*, not only when one failed: a suite that could not run
  has not said the work is good.

### Examples

```pycon
>>> validate("x.mp4").ok
True
```

### nw.work_item_instance_id(transform_name, mapping_key)

The instance id of one fan-out unit: UUIDv5 of `(transform_name, mapping_key)`.

A **pure function**, deliberately: pure is async-safe by construction
(no ambient counter for a suspended coroutine to corrupt — the ComfyUI
`GraphBuilder` race) and stable under insertion (adding an item never
changes any other item’s id). The same (transform, key) pair yields the
same id on every machine, every run, forever — which is what makes
per-instance retry, cost attribution, and “regenerate just this one”
addressable across runs.

* **Return type:**
  [`UUID`](https://docs.python.org/3/library/uuid.html#uuid.UUID)

```pycon
>>> a = work_item_instance_id("panel_to_image.fal", "scene_1/panel_2")
>>> a == work_item_instance_id("panel_to_image.fal", "scene_1/panel_2")
True
>>> a != work_item_instance_id("panel_to_voiceover", "scene_1/panel_2")
True
```

### Modules

| [`freshness`](_autosummary/nw.freshness.html.md#module-nw.freshness)                     | Freshness with **early cutoff** — what is *actually* out of date.                                                               |
|----------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------|
| [`inspect`](_autosummary/nw.inspect.html.md#module-nw.inspect)                         | QA helpers — typed reports about rendered shots.                                                                                |
| [`checks`](_autosummary/nw.checks.html.md#module-nw.checks)                           | The checks nw itself ships — a small, honest default menu.                                                                      |
| [`bodies`](_autosummary/nw.bodies.html.md#module-nw.bodies)                           | Body schemas for nw's project-graph annotations.                                                                                |
| [`delivery`](_autosummary/nw.delivery.html.md#module-nw.delivery)                       | What a genre hands back when a human wants to *hold* what it made.                                                              |
| [`experiment`](_autosummary/nw.experiment.html.md#module-nw.experiment)                   | Experiment helpers — clone projects, apply operations across siblings.                                                          |
| [`genres`](_autosummary/nw.genres.html.md#nw.genres)                                  | A typed dict-backed plugin registry.                                                                                            |
| [`graph`](_autosummary/nw.graph.html.md#module-nw.graph)                             | The project annotation graph — read/write helpers + reelee-style traversals.                                                    |
| [`graph_backend`](_autosummary/nw.graph_backend.html.md#module-nw.graph_backend)             | Config-driven backend selection for nw's annotation graph stores.                                                               |
| [`jobs`](_autosummary/nw.jobs.html.md#module-nw.jobs)                               | nw.jobs — a project-scoped async **job** facade over `au`.                                                                      |
| [`migrate`](_autosummary/nw.migrate.html.md#module-nw.migrate)                         | Idempotent migration: project.json (sections/shots/refs) → lacing graph.                                                        |
| [`pricing`](_autosummary/nw.pricing.html.md#module-nw.pricing)                         | Re-quoting a persisted plan at today's rates (nw#74).                                                                           |
| [`project`](_autosummary/nw.project.html.md#module-nw.project)                         | Project facade: a folder on disk → typed reads, typed writes, typed summary.                                                    |
| [`renderers`](_autosummary/nw.renderers.html.md#module-nw.renderers)                     | Render strategies — pluggable, plan-producing, **shot-typed**.                                                                  |
| [`schema`](_autosummary/nw.schema.html.md#module-nw.schema)                           | Schema for an nw project — narrative-workflow SSOT data shapes.                                                                 |
| [`script_segmentation`](_autosummary/nw.script_segmentation.html.md#module-nw.script_segmentation) | `nw.script_segmentation` — narrow LLM-backed helper that converts a free-form script into a list of storyboard-panel proposals. |
| [`secrets`](_autosummary/nw.secrets.html.md#module-nw.secrets)                         | Execution secrets — credentials that reach `execute` and nothing else.                                                          |
| [`storyboard`](_autosummary/nw.storyboard.html.md#module-nw.storyboard)                   | Storyboard ↔ Project bridge.                                                                                                    |
| [`transforms`](_autosummary/nw.transforms.html.md#nw.transforms)                          | A typed dict-backed plugin registry.                                                                                            |
| [`validation`](_autosummary/nw.validation.html.md#module-nw.validation)                   | Pluggable validation of finished work — the seam, not the checks.                                                               |
| [`workflow`](_autosummary/nw.workflow.html.md#module-nw.workflow)                       | Workflow: prepare → plan → execute, for a **video shot**.                                                                       |


# _autosummary/nw.inspect.html.md

# nw.inspect

QA helpers — typed reports about rendered shots.

A render finishing without an exception doesn’t mean it’s *right*. The v3
fixture in muvid_project rendered successfully but produced a 5.87s clip
when 8s were asked for. The four-second freeze in v4 was visible only by
extracting frames and md5’ing them. These reports surface those defects
without a manual ffprobe / ffmpeg dance.

Public surface:

- [`shot_report()`](_autosummary/nw.inspect.html.md#nw.inspect.shot_report) — duration, frozen-frame segments, audio-video offset,
  whether the output is the requested length.
- [`compose_report()`](_autosummary/nw.inspect.html.md#nw.inspect.compose_report) — same but for the final composed video; plus
  per-shot rollup, gaps between shots, freeze alerts.

Both return frozen Pydantic models for typed downstream use.

### Functions

| [`compose_report`](_autosummary/nw.inspect.html.md#nw.inspect.compose_report)(project, \*[, ...])       | Per-shot reports + final-compose inspection in one call.        |
|-------------------------------------------------------------------------------------------|-----------------------------------------------------------------|
| [`shot_report`](_autosummary/nw.inspect.html.md#nw.inspect.shot_report)(project, shot_id, \*[, ...]) | Inspect `shots/<shot_id>/output.mp4` and return a typed report. |

### Classes

| [`ComposeReport`](_autosummary/nw.inspect.html.md#nw.inspect.ComposeReport)(\*\*data)   | Inspection of the project-level final composed video.   |
|----------------------------------------------------------------------------|---------------------------------------------------------|
| [`FrozenSegment`](_autosummary/nw.inspect.html.md#nw.inspect.FrozenSegment)(\*\*data)   | A run of consecutive frames whose pixels don't change.  |
| [`Gap`](_autosummary/nw.inspect.html.md#nw.inspect.Gap)(\*\*data)             | A gap on the timeline between two shots.                |
| [`ShotReport`](_autosummary/nw.inspect.html.md#nw.inspect.ShotReport)(\*\*data)      | Inspection of one rendered shot.                        |

### *class* nw.inspect.ComposeReport(\*\*data)

Bases: `BaseModel`

Inspection of the project-level final composed video.

#### model_config *: [ClassVar](https://docs.python.org/3/library/typing.html#typing.ClassVar)[ConfigDict]* *= {'frozen': True}*

Configuration for the model, should be a dictionary conforming to [`ConfigDict`][pydantic.config.ConfigDict].

### *class* nw.inspect.FrozenSegment(\*\*data)

Bases: `BaseModel`

A run of consecutive frames whose pixels don’t change.

A short freeze (≤ 0.25s) is usually a model artifact; a long one (≥ 1s)
is almost always a bug — Hailuo Pro returning a too-short clip + a tpad
fallback that froze the last frame, etc.

#### model_config *: [ClassVar](https://docs.python.org/3/library/typing.html#typing.ClassVar)[ConfigDict]* *= {}*

Configuration for the model, should be a dictionary conforming to [`ConfigDict`][pydantic.config.ConfigDict].

### *class* nw.inspect.Gap(\*\*data)

Bases: `BaseModel`

A gap on the timeline between two shots.

#### model_config *: [ClassVar](https://docs.python.org/3/library/typing.html#typing.ClassVar)[ConfigDict]* *= {'frozen': True}*

Configuration for the model, should be a dictionary conforming to [`ConfigDict`][pydantic.config.ConfigDict].

### *class* nw.inspect.ShotReport(\*\*data)

Bases: `BaseModel`

Inspection of one rendered shot.

#### *property* has_long_freeze *: [bool](https://docs.python.org/3/builtins/functions.html#bool)*

Any freeze ≥ 1.0s is suspicious. Anything ≥ 0.5s is worth flagging.

#### model_config *: [ClassVar](https://docs.python.org/3/library/typing.html#typing.ClassVar)[ConfigDict]* *= {'frozen': True}*

Configuration for the model, should be a dictionary conforming to [`ConfigDict`][pydantic.config.ConfigDict].

### nw.inspect.compose_report(project, , freeze_sample_fps=4.0, duration_tolerance_s=0.1)

Per-shot reports + final-compose inspection in one call.

* **Return type:**
  [`ComposeReport`](_autosummary/nw.inspect.html.md#nw.inspect.ComposeReport)

### nw.inspect.shot_report(project, shot_id, , freeze_sample_fps=4.0, duration_tolerance_s=0.1)

Inspect `shots/<shot_id>/output.mp4` and return a typed report.

* **Parameters:**
  * **project** ([`Project`](_autosummary/nw.project.html.md#nw.project.Project)) – The [`nw.Project`](_autosummary/nw.html.md#nw.Project).
  * **shot_id** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str)) – The shot id.
  * **freeze_sample_fps** ([`float`](https://docs.python.org/3/builtins/functions.html#float)) – How many frames per second to extract for the
    freeze detector (default 4 fps; a freeze must hold across at
    least two consecutive samples to count).
  * **duration_tolerance_s** ([`float`](https://docs.python.org/3/builtins/functions.html#float)) – Acceptable difference between actual and
    target duration before flagging.
* **Return type:**
  [`ShotReport`](_autosummary/nw.inspect.html.md#nw.inspect.ShotReport)
* **Returns:**
  A [`ShotReport`](_autosummary/nw.inspect.html.md#nw.inspect.ShotReport).


# _autosummary/nw.jobs.html.md

# nw.jobs

nw.jobs — a project-scoped async **job** facade over `au`.

A “job” is one long, cancellable unit of render work (a full-auto journey, a
single `panel.animate`, an `assemble_animatic` pass, …). It has a durable
id, a persistent terminal state, live progress + ETA, a cost, and a cancel
entry keyed by that id — everything a task tray needs and none of which a bare
HTTP request provides.

This module is the *only* place async substance lives for the render layer:
`au` supplies the submit → poll → result skeleton and the durable store;
`nw.jobs` adds the five render-domain concerns `au` has no data model for —
progress %, ETA, human label/kind, idempotency (dedup), and cost — plus the
5-state normalization, context-capture so a job outlives its request, and the
active-jobs index that makes membership meaningful. Consumers (reelee’s
`/api/jobs` closures) stay thin: they call [`enqueue()`](_autosummary/nw.jobs.html.md#nw.jobs.enqueue) / [`estimate()`](_autosummary/nw.jobs.html.md#nw.jobs.estimate)
/ [`list_jobs()`](_autosummary/nw.jobs.html.md#nw.jobs.list_jobs) / [`get_job()`](_autosummary/nw.jobs.html.md#nw.jobs.get_job) / [`cancel_job()`](_autosummary/nw.jobs.html.md#nw.jobs.cancel_job) / [`to_dict()`](_autosummary/nw.jobs.html.md#nw.jobs.to_dict)
and serialize the result.

Design decisions (from the `nw.jobs`-on-`au` design report,
`misc/docs/research/async-job-manager-on-au.md`):

- \*\*Backend = `au.ThreadBackend``**, not ``ProcessBackend`. A fal render is
  I/O-bound (a blocking upstream wait), and the worker must share the live
  in-process `Project` graph and write events to the same channel the
  existing SSE tails. Process isolation buys no real fal cancel (fal still
  bills) while breaking the live-progress channel. Where bounded concurrent
  paid renders matter, swap in `StdLibQueueBackend(use_processes=False)` —
  a construction detail behind this facade.
- **au store is SSOT for \*status\* only.** `ThreadBackend` overwrites the
  store record with a bare `ComputationResult` at start (RUNNING) and end
  (COMPLETED), carrying no metadata — so every job-semantic field lives in a
  per-project **active-jobs index** (a `dol` mapping), which `au` cannot
  clobber. The index is also the membership authority: `au.FileSystemStore`
  synthesizes `PENDING` for a missing key, so store membership is
  meaningless.
- **Idempotency**: the au store key *is* the idempotency key
  (`sha256(project:kind:plan_hash-or-params)`). A resubmit while a job with
  that key is live returns the existing job instead of launching a duplicate.
- **Cancel is boundary-grained and race-proof.** `cancel_job` flips the au
  record terminal *and* sets a durable should-cancel flag; the `cancel_requested`
  flag is the authority for cancellation intent, so a job reads
  `cancelling` → `cancelled` regardless of whether the still-running
  `ThreadBackend` thread later clobbers the store with COMPLETED.
- **ETA is learned from observed wall-time**, per `(model, operation, dur_bucket)`
  with back-off, median (not mean), honest “estimating…” before enough
  history, and **cache-hits excluded from learning**.
- **A cost never travels without its honesty flag.** `JobCost.actual_usd` is
  paired with `actual_is_lower_bound`, because an unpriceable call that
  actually billed contributes `0.0` to the sum — so a bare `$0` means
  *either* “nothing was spent” *or* “we do not know what was spent”, and a
  spend surface that cannot tell them apart shows the second as free.
- **An estimate is re-quoted, never remembered.** When `params["plan"]` is
  supplied, [`estimate()`](_autosummary/nw.jobs.html.md#nw.jobs.estimate) and [`enqueue()`](_autosummary/nw.jobs.html.md#nw.jobs.enqueue) price it through
  [`nw.pricing.current_quote()`](_autosummary/nw.pricing.html.md#nw.pricing.current_quote) at today’s rates rather than trusting a
  > caller-supplied `estimated_usd` frozen at plan time — falaw’s rate tables
  > move, and a stale figure under-quotes the run (nw#74). Descriptive only:
  > `cost_basis` stays out of `plan_hash`, so the idempotency key above is
  > byte-identical to what it was before repricing existed, and a resumed render
  > still dedups onto work already paid for.

All tunables are keyword-configurable via [`JobsConfig`](_autosummary/nw.jobs.html.md#nw.jobs.JobsConfig); defaults live at
the top of this module — no magic numbers below.

### Module Attributes

| [`UNREADABLE_PLAN_REASON`](_autosummary/nw.jobs.html.md#nw.jobs.UNREADABLE_PLAN_REASON)   | Why a quote came back unknown when the plan itself was unreadable.                             |
|---------------------------------------------------------------------------|------------------------------------------------------------------------------------------------|
| [`ERROR_KIND_REFUSED`](_autosummary/nw.jobs.html.md#nw.jobs.ERROR_KIND_REFUSED)       | `Job.error_kind` values — see [`Job.error_kind`](_autosummary/nw.jobs.html.md#nw.jobs.Job.error_kind). |

### Functions

| [`cancel_job`](_autosummary/nw.jobs.html.md#nw.jobs.cancel_job)(project, job_id, \*[, config])         | Request cancellation.                                                                                       |
|----------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------|
| [`enqueue`](_autosummary/nw.jobs.html.md#nw.jobs.enqueue)(project, kind, params, \*[, ...])         | Enqueue a billable render as a background job.                                                              |
| [`error_kind_of`](_autosummary/nw.jobs.html.md#nw.jobs.error_kind_of)(error)                              | `"refused"` | `"cancelled"` | `"crashed"` for an exception a job raised.                                    |
| [`estimate`](_autosummary/nw.jobs.html.md#nw.jobs.estimate)(project, kind, params, \*[, config])     | Dry-run cost gate **without enqueueing**.                                                                   |
| [`get_job`](_autosummary/nw.jobs.html.md#nw.jobs.get_job)(project, job_id, \*[, config])            | One job (projecting the au status + mirrored index metadata).                                               |
| [`list_jobs`](_autosummary/nw.jobs.html.md#nw.jobs.list_jobs)(project, \*[, status, limit, config])   | Jobs for this project, **newest first**, optionally filtered by status.                                     |
| [`predict_total_s`](_autosummary/nw.jobs.html.md#nw.jobs.predict_total_s)(eta_candidates, output_kind, ...) | Predict the total render seconds for a job as `(p50, p90, confidence)`.                                     |
| [`summarize`](_autosummary/nw.jobs.html.md#nw.jobs.summarize)(project_root, \*[, limit, config])      | This project's jobs **without provisioning it**.                                                            |
| [`to_dict`](_autosummary/nw.jobs.html.md#nw.jobs.to_dict)(job)                                      | Serialize a [`Job`](_autosummary/nw.jobs.html.md#nw.jobs.Job) to the JSON contract (design report §7.4). |

### Classes

| [`DurationLearningMiddleware`](_autosummary/nw.jobs.html.md#nw.jobs.DurationLearningMiddleware)(\*, durations, ...)    | Keyed, percentile duration learner — a cousin of `au.MetricsMiddleware`.                                       |
|----------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------|
| [`Job`](_autosummary/nw.jobs.html.md#nw.jobs.Job)(job_id, kind, label, status, idempotency_key) | Projected, JSON-serializable view of one job (see [`to_dict()`](_autosummary/nw.jobs.html.md#nw.jobs.to_dict)). |
| [`JobCost`](_autosummary/nw.jobs.html.md#nw.jobs.JobCost)([estimated_usd, actual_usd, ...])         |                                                                                                                |
| [`JobProgress`](_autosummary/nw.jobs.html.md#nw.jobs.JobProgress)([stage_index, stage_count, ...])      |                                                                                                                |
| [`JobsConfig`](_autosummary/nw.jobs.html.md#nw.jobs.JobsConfig)([n_min, sample_window_k, ...])         | Tunables for the job manager.                                                                                  |

### *class* nw.jobs.DurationLearningMiddleware(, durations, index, lock, config=JobsConfig(n_min=3, sample_window_k=20, pct_ceil=99, overrun_factor=1.5, cache_hit_floor_s=0.5, dur_buckets_s=(4.0, 8.0, 12.0), prior_total_s={'image': 12.0, 'video': 90.0, 'audio': 15.0}, default_prior_total_s=30.0, stale_running_s=900.0, heartbeat_interval_s=20.0, heartbeat_stale_s=120.0, approval_threshold_usd=1.0, jobs_dirname='.nw/jobs'))

Bases: `Middleware`

Keyed, percentile duration learner — a cousin of `au.MetricsMiddleware`.

Records the render wall-time under the job’s ETA-key candidates, so a cold
specific key backs off to a warmer coarse one. The specific `learned`
keys get the whole-job sample. The coarse `output_kind` key is \*\*shared
across operations\*\* and means *seconds per unit*, so a job that has
specific keys writes it only when it declared `params["units"]`, and
then with `elapsed / units`. Such a job of unknown multiplicity never
touches the shared bucket: forgetting `units` costs a cold coarse
bucket, never a corrupted one (nw#67 — a 4-render job used to write its
4-fold duration into the `image` bucket single-image jobs read from).
A job whose *only* key is the coarse one (no model/operation) keeps
learning in it as before — it has nowhere else to learn. Such a job that
really covers N units should still declare `units`, or it writes its
N-fold duration into the shared bucket, exactly as before nw#67. Two deliberate departures
from `au`’s built-in metrics:

- **Self-timed** (`time.monotonic` in `before_compute` → `after_compute`)
  rather than reading `result.duration`: `ThreadBackend` constructs a
  *fresh* COMPLETED `ComputationResult` whose `created_at` is the
  completion instant, so `result.duration` is ~0 and useless. (Surfaced
  as an `au` finding.)
- **Cache-hits are never learned** — a ~0s cache hit would drag the median
  to zero. The job’s `cached` flag (mirrored from a `cache_hit` event
  during the run) gates recording.

Its `_start` map doubles as the in-process **liveness signal** the stale
reaper uses (a RUNNING au record whose key is not in `_start` and whose
`started_at` is old is a dead worker).

#### after_compute(key, result)

Called after computation completes.

* **Return type:**
  [`None`](https://docs.python.org/3/builtins/constants.html#None)

#### before_compute(func, args, kwargs, key)

Called before computation starts.

* **Return type:**
  [`None`](https://docs.python.org/3/builtins/constants.html#None)

#### on_error(key, error)

Called when computation fails.

* **Return type:**
  [`None`](https://docs.python.org/3/builtins/constants.html#None)

### nw.jobs.ERROR_KIND_REFUSED *= 'refused'*

`Job.error_kind` values — see [`Job.error_kind`](_autosummary/nw.jobs.html.md#nw.jobs.Job.error_kind).

### *class* nw.jobs.Job(job_id, kind, label, status, idempotency_key, params=<factory>, created_at=None, started_at=None, finished_at=None, queue_wait_s=None, elapsed_s=None, progress=<factory>, predicted_total_s=None, remaining_s=None, eta_ts=None, eta_s=None, pct=None, confidence=None, label_hint=None, eta_key=None, cost=<factory>, cached=False, worker_silent_s=None, worker_responsive=None, artifact_ref=None, result=None, error=None, error_kind=None, run_id=None, last_event_id=None)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

Projected, JSON-serializable view of one job (see [`to_dict()`](_autosummary/nw.jobs.html.md#nw.jobs.to_dict)).

#### error_kind *: [str](https://docs.python.org/3/builtins/stdtypes.html#str) | [None](https://docs.python.org/3/builtins/constants.html#None)* *= None*

`"refused"` (the op
raised [`nw.GenreOpRefused`](_autosummary/nw.html.md#nw.GenreOpRefused) — a deliberate refusal with a message for the
person), `"cancelled"` (stopped on request, or the op raised
[`nw.GenreOpCancelled`](_autosummary/nw.html.md#nw.GenreOpCancelled)), `"crashed"` (anything else — a bug, or a worker
that stopped beating). `None` while running and on success. `error` keeps the
text either way.

* **Type:**
  Why a job did not succeed, for a screen to say so

#### worker_responsive *: [bool](https://docs.python.org/3/builtins/functions.html#bool) | [None](https://docs.python.org/3/builtins/constants.html#None)* *= None*

Whether the worker is provably still alive, **by the reaper’s own rule**.

Derived from the same `_heartbeat_is_fresh` predicate `_maybe_reap`
consults, so a UI and the reaper can never disagree about what “alive”
means — a second definition living in a client is how a screen ends up
insisting a job is fine while the server is failing it.

`None` means *unknowable*, not *dead*: a job that is not running, or one
that never beat. `False` is a positive claim that contact has been lost.

#### worker_silent_s *: [float](https://docs.python.org/3/builtins/functions.html#float) | [None](https://docs.python.org/3/builtins/constants.html#None)* *= None*

Seconds since this job’s worker last stamped a heartbeat.

`None` when it has never beaten — a record written before heartbeats
existed, or a worker that died before its first beat. Absence is not a
duration, and rendering `None` as `0` would report the silent case as
the healthiest one.

### *class* nw.jobs.JobCost(estimated_usd=None, actual_usd=None, cache_hit_savings_usd=None, actual_is_lower_bound=None)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

#### actual_is_lower_bound *: [bool](https://docs.python.org/3/builtins/functions.html#bool) | [None](https://docs.python.org/3/builtins/constants.html#None)* *= None*

True when the run reported `has_unknown_costs` — some call that
actually billed had no price, so `actual_usd` UNDER-states the spend.

Its whole job is to keep a `$0` readable. Without it, `actual_usd`
conflates two answers a spend surface must never merge: “this run cost
nothing” (a cache hit — a known zero) and “we do not know what this run
cost” (an unpriceable call ran). A UI that renders the second as *free*,
or a bound that reads it as *under budget*, is the failure this field
exists to make impossible.

`None` means the render never reported either way — an older caller, or
a job that died before finishing. `None` is not `False`: absence of the
flag is not a claim that the total is exact.

#### estimated_usd *: [float](https://docs.python.org/3/builtins/functions.html#float) | [None](https://docs.python.org/3/builtins/constants.html#None)* *= None*

Predicted spend, re-quoted at today’s rates when a plan was supplied.

`None` means unknown, and unknown always requires approval — it is what
a plan whose calls carry no `falaw.CostBasis` re-prices to. Never
the plan-time figure passed in alongside such a plan: that number was true
when it was written and falaw’s rate tables have moved since (nw#74).

### *class* nw.jobs.JobProgress(stage_index=None, stage_count=None, current_transform=None, fraction=None)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

### *class* nw.jobs.JobsConfig(n_min=3, sample_window_k=20, pct_ceil=99, overrun_factor=1.5, cache_hit_floor_s=0.5, dur_buckets_s=(4.0, 8.0, 12.0), prior_total_s=<factory>, default_prior_total_s=30.0, stale_running_s=900.0, heartbeat_interval_s=20.0, heartbeat_stale_s=120.0, approval_threshold_usd=1.0, jobs_dirname='.nw/jobs')

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

Tunables for the job manager. All keyword-configurable; sensible defaults.

ETA knobs (`n_min` … `prior_total_s`) mirror the design report §5.7.

#### approval_threshold_usd *: [float](https://docs.python.org/3/builtins/functions.html#float)* *= 1.0*

Estimated cost at/above which a render requires explicit approval.

#### cache_hit_floor_s *: [float](https://docs.python.org/3/builtins/functions.html#float)* *= 0.5*

Predicted total for an all-cache-hit plan (`confidence="exact"`).

#### default_prior_total_s *: [float](https://docs.python.org/3/builtins/functions.html#float)* *= 30.0*

Prior when even the output kind is unknown.

#### dur_buckets_s *: [tuple](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[float](https://docs.python.org/3/builtins/functions.html#float), ...]* *= (4.0, 8.0, 12.0)*

Upper edges of the output-duration buckets for `per_second` models.

#### heartbeat_interval_s *: [float](https://docs.python.org/3/builtins/functions.html#float)* *= 20.0*

How often a running worker stamps `heartbeat_at` on its index record.

Liveness has to be a fact in the **shared store**, not in one process’s
memory, or a second API replica cannot tell a live job from a dead one.
Cheap: one small atomic file write per job per interval.

#### heartbeat_stale_s *: [float](https://docs.python.org/3/builtins/functions.html#float)* *= 120.0*

A heartbeat younger than this proves the worker is alive **anywhere**.

Generously larger than `heartbeat_interval_s`: the beat is a Python
thread, and a render holding the GIL in a C extension can delay it well
past one interval. The asymmetry is deliberate — a late beat costs a
slower reap, while an eager one destroys a live job’s record.

#### jobs_dirname *: [str](https://docs.python.org/3/builtins/stdtypes.html#str)* *= '.nw/jobs'*

Sub-path under `project.root` for the job stores (nw’s `.nw/` convention).

#### n_min *: [int](https://docs.python.org/3/builtins/functions.html#int)* *= 3*

Minimum samples for a key before its prediction is `"learned"` (not prior).

#### overrun_factor *: [float](https://docs.python.org/3/builtins/functions.html#float)* *= 1.5*

Synthesize `p90 = p50 * overrun_factor` when a real p90 isn’t available.

#### pct_ceil *: [int](https://docs.python.org/3/builtins/functions.html#int)* *= 99*

Never *compute* 100% — only the → succeeded transition sets 100.

#### prior_total_s *: [Mapping](https://docs.python.org/3/library/collections.abc.html#collections.abc.Mapping)[[str](https://docs.python.org/3/builtins/stdtypes.html#str), [float](https://docs.python.org/3/builtins/functions.html#float)]*

Cold-start priors by output kind (drives the honest “estimating…” label).

#### sample_window_k *: [int](https://docs.python.org/3/builtins/functions.html#int)* *= 20*

Keep only the most-recent K duration samples per key (robust to drift).

#### stale_running_s *: [float](https://docs.python.org/3/builtins/functions.html#float)* *= 900.0*

A RUNNING record older than this with no live worker is reaped as
`FAILED("worker died — resumable")` (kills the stuck-toast bug).

### nw.jobs.UNREADABLE_PLAN_REASON *= "params['plan'] could not be read as a falaw Plan, so its cost cannot be quoted; see the server log for what went wrong"*

Why a quote came back unknown when the plan itself was unreadable.

A fixed sentence rather than the exception text: this string reaches a job
surface, and an exception’s message is written for an operator reading a log,
not for whoever is deciding whether to approve a spend.

### nw.jobs.cancel_job(project, job_id, , config=JobsConfig(n_min=3, sample_window_k=20, pct_ceil=99, overrun_factor=1.5, cache_hit_floor_s=0.5, dur_buckets_s=(4.0, 8.0, 12.0), prior_total_s={'image': 12.0, 'video': 90.0, 'audio': 15.0}, default_prior_total_s=30.0, stale_running_s=900.0, heartbeat_interval_s=20.0, heartbeat_stale_s=120.0, approval_threshold_usd=1.0, jobs_dirname='.nw/jobs'))

Request cancellation. **Idempotent.** `None` if unknown.

Sets a durable should-cancel flag (a running stage stops at its next
boundary) *and* flips the au record terminal at once via `au.cancel_task`
(which builds the handle **with the backend** so `terminate` is reached).
The `cancel_requested` flag is authoritative, so the job reads
`cancelling` → `cancelled` even if the still-running thread later
overwrites the store with COMPLETED.

**A job that has already finished is not cancelled — the call is a no-op**
and returns the job unchanged. Because the flag is authoritative for
status, setting it on a terminal record rewrote a SUCCEEDED job to
`cancelled` and dropped its `result` and `pct` (and a FAILED job’s
`error`): work that ran, finished and *billed*, reported as though it had
not. Cancelling is a request about the future, and there is no future left
to change.

* **Return type:**
  [`Job`](_autosummary/nw.jobs.html.md#nw.jobs.Job) | [`None`](https://docs.python.org/3/builtins/constants.html#None)

### nw.jobs.enqueue(project, kind, params, , on_event=None, dispatch=None, backend=None, idempotency_key=None, label=None, capture_context=None, secrets=None, config=JobsConfig(n_min=3, sample_window_k=20, pct_ceil=99, overrun_factor=1.5, cache_hit_floor_s=0.5, dur_buckets_s=(4.0, 8.0, 12.0), prior_total_s={'image': 12.0, 'video': 90.0, 'audio': 15.0}, default_prior_total_s=30.0, stale_running_s=900.0, heartbeat_interval_s=20.0, heartbeat_stale_s=120.0, approval_threshold_usd=1.0, jobs_dirname='.nw/jobs'))

Enqueue a billable render as a background job. Returns a [`Job`](_autosummary/nw.jobs.html.md#nw.jobs.Job)
(`status="queued"`) **immediately** — the call never blocks and never
bills on the calling thread.

The request context (fal credentials + the resolved `Project`) is
captured now and re-established inside the worker so the job outlives its
request. If a *live* (non-terminal) job with the same `idempotency_key`
already exists, that job is returned instead of launching a duplicate.

* **Parameters:**
  * **project** – the `nw.Project` the render operates on.
  * **kind** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str)) – dispatch key selecting the render callable (e.g.
    `"journey.full_auto"`, `"panel.animate"`).
  * **params** ([`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)) – render parameters (also the ETA-key + default-idempotency
    basis). A `"plan"` entry must be a **\`\`falaw.plan_to_dict\`\`
    dict, not a live** `falaw.Plan`: the whole `params`
    mapping is JSON-serialized into the job index, so a `Plan`
    object raises there. The dict hashes to the identical
    `plan_hash` (`_plan_for_identity()`) and re-quotes the same
    way, so nothing is lost by serializing it — [`estimate()`](_autosummary/nw.jobs.html.md#nw.jobs.estimate),
    which never writes a record, accepts either. A `"units"` entry
    (a positive `int`) says how many `output_kind` units the
    job’s wall-time covers — `4` for four image renders. Only a job
    that declares it teaches the shared coarse `output_kind` ETA
    bucket, per unit (nw#67); see [`DurationLearningMiddleware`](_autosummary/nw.jobs.html.md#nw.jobs.DurationLearningMiddleware).
  * **on_event** ([`Callable`](https://docs.python.org/3/library/collections.abc.html#collections.abc.Callable)[[[`Any`](https://docs.python.org/3/library/typing.html#typing.Any)], [`None`](https://docs.python.org/3/builtins/constants.html#None)] | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – sink for the render’s lifecycle events (reelee wires this to
    its `agent_log` / SSE tail). Events are stamped with
    `job_id`/`run_id` and mirrored into progress/cost/eta.
  * **dispatch** ([`Mapping`](https://docs.python.org/3/library/collections.abc.html#collections.abc.Mapping)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Callable`](https://docs.python.org/3/library/collections.abc.html#collections.abc.Callable)] | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – `{kind: callable}` table. Each callable is invoked as
    `callable(project, params, *, job_id, on_event, should_cancel)`
    (only the kwargs it declares are passed) and should return a
    JSON-serializable result payload.
  * **backend** (`ComputationBackend` | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – advanced override; default is the managed `ThreadBackend`
    (which carries the duration-learning middleware + liveness map).
  * **idempotency_key** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str) | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – dedup handle; default derived from `falaw.plan_hash`
    of `params["plan"]` when present, else a stable hash of params.
  * **label** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str) | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – human tray label; default derived from `kind`/`params`.
  * **capture_context** ([`Callable`](https://docs.python.org/3/library/collections.abc.html#collections.abc.Callable)[[], [`AbstractContextManager`](https://docs.python.org/3/library/contextlib.html#contextlib.AbstractContextManager)[[`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]] | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – optional caller-supplied context hook. Called **now**
    (on the request thread) to snapshot any request-scoped state the
    caller needs re-established inside the worker, returning a context
    manager entered around the render on the worker thread. `nw.jobs`
    handles fal credentials itself (`falaw` is its dependency); this
    hook is how a caller re-binds credentials it owns without `nw`
    importing them — e.g. reelee’s BYO vision (aix) + ElevenLabs keys,
    which otherwise fall back to owner/env in a background job because
    `ThreadBackend` does not copy `ContextVars` into the worker.
  * **secrets** ([`Mapping`](https://docs.python.org/3/library/collections.abc.html#collections.abc.Mapping)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)] | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – the caller’s per-call credentials ([`nw.Secrets`](_autosummary/nw.html.md#nw.Secrets); any
    mapping is coerced), for a render that spends a bring-your-own
    key. Held **in memory only**: never written to the job index
    (`params` is — never put a key there), never logged, and it
    reaches the render callable only when that callable declares a
    `secrets` keyword — the same accepts-it-or-not rule as
    `job_id` / `on_event` / `should_cancel`. A `"fal"` secret
    is also bound as the worker’s fal credential
    ([`nw.secrets.using_secrets()`](_autosummary/nw.secrets.html.md#nw.secrets.using_secrets)), innermost, so an explicit key
    wins over an ambient one. The explicit counterpart of
    `capture_context`: what that hook re-binds ambiently, this
    threads by hand.
  * **config** ([`JobsConfig`](_autosummary/nw.jobs.html.md#nw.jobs.JobsConfig)) – tunables (see [`JobsConfig`](_autosummary/nw.jobs.html.md#nw.jobs.JobsConfig)).
* **Raises:**
  * [**KeyError**](https://docs.python.org/3/builtins/exceptions.html#KeyError) – if `kind` is not in `dispatch`.
  * [**ValueError**](https://docs.python.org/3/builtins/exceptions.html#ValueError) – if `params["units"]` is present but not a positive `int`.
* **Return type:**
  [`Job`](_autosummary/nw.jobs.html.md#nw.jobs.Job)

### nw.jobs.error_kind_of(error)

`"refused"` | `"cancelled"` | `"crashed"` for an exception a job raised.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

```pycon
>>> from nw.genres import GenreOpRefused, GenreOpCancelled
>>> [error_kind_of(e) for e in (GenreOpRefused("no song"),
...                             GenreOpCancelled(), ValueError("bug"))]
['refused', 'cancelled', 'crashed']
```

### nw.jobs.estimate(project, kind, params, , config=JobsConfig(n_min=3, sample_window_k=20, pct_ceil=99, overrun_factor=1.5, cache_hit_floor_s=0.5, dur_buckets_s=(4.0, 8.0, 12.0), prior_total_s={'image': 12.0, 'video': 90.0, 'audio': 15.0}, default_prior_total_s=30.0, stale_running_s=900.0, heartbeat_interval_s=20.0, heartbeat_stale_s=120.0, approval_threshold_usd=1.0, jobs_dirname='.nw/jobs'))

Dry-run cost gate **without enqueueing**.

Returns `{estimated_usd, has_unknown_costs, approval_threshold_usd,
requires_approval, quote}`. Unknown cost always requires approval
(preserves the one-price-per-clip gate).

**The gate quotes, it does not remember.** When `params["plan"]` is
present its cost is re-quoted at today’s rates through
[`nw.pricing.current_quote()`](_autosummary/nw.pricing.html.md#nw.pricing.current_quote), and a caller-supplied
`params["estimated_usd"]` is ignored — exactly as
`_default_idempotency_key()` ignores it for the dedup basis. A
persisted plan’s figure is frozen at plan time, and falaw’s rate tables
move (0.0.46 re-quoted premium LLM calls tenfold upward), so gating on
the stored number under-quotes the run: the one direction a spend
decision must never err in (nw#74).

A supplied plan that cannot be re-quoted — no `cost_basis` on its calls,
an unparseable payload, a model that has left the catalogue — yields
`estimated_usd=None`, which requires approval. Refusing to name a price
is the safe answer; repeating yesterday’s is not.

Without a plan the gate falls back to `params["estimated_usd"]`, whose
provenance nw cannot see; `quote` is then `None` to say so. When there
*is* a quote it carries `caller_estimated_usd` — what the caller passed,
reported beside today’s number rather than discarded, so a surface can
show the movement.

Raises `ValueError` on a malformed `params["units"]`, exactly as
[`enqueue()`](_autosummary/nw.jobs.html.md#nw.jobs.enqueue) does, so the gate never approves what enqueue refuses.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### nw.jobs.get_job(project, job_id, , config=JobsConfig(n_min=3, sample_window_k=20, pct_ceil=99, overrun_factor=1.5, cache_hit_floor_s=0.5, dur_buckets_s=(4.0, 8.0, 12.0), prior_total_s={'image': 12.0, 'video': 90.0, 'audio': 15.0}, default_prior_total_s=30.0, stale_running_s=900.0, heartbeat_interval_s=20.0, heartbeat_stale_s=120.0, approval_threshold_usd=1.0, jobs_dirname='.nw/jobs'))

One job (projecting the au status + mirrored index metadata). `None` if
unknown. Reaps a stale-RUNNING record on read.

* **Return type:**
  [`Job`](_autosummary/nw.jobs.html.md#nw.jobs.Job) | [`None`](https://docs.python.org/3/builtins/constants.html#None)

### nw.jobs.list_jobs(project, , status=None, limit=50, config=JobsConfig(n_min=3, sample_window_k=20, pct_ceil=99, overrun_factor=1.5, cache_hit_floor_s=0.5, dur_buckets_s=(4.0, 8.0, 12.0), prior_total_s={'image': 12.0, 'video': 90.0, 'audio': 15.0}, default_prior_total_s=30.0, stale_running_s=900.0, heartbeat_interval_s=20.0, heartbeat_stale_s=120.0, approval_threshold_usd=1.0, jobs_dirname='.nw/jobs'))

Jobs for this project, **newest first**, optionally filtered by status.

Backed by the per-project active-jobs index (not by scanning the au store,
whose missing-key-returns-PENDING gotcha makes membership meaningless).

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`Job`](_autosummary/nw.jobs.html.md#nw.jobs.Job)]

### nw.jobs.predict_total_s(eta_candidates, output_kind, , durations, expected_cache_hit=False, units=None, config=JobsConfig(n_min=3, sample_window_k=20, pct_ceil=99, overrun_factor=1.5, cache_hit_floor_s=0.5, dur_buckets_s=(4.0, 8.0, 12.0), prior_total_s={'image': 12.0, 'video': 90.0, 'audio': 15.0}, default_prior_total_s=30.0, stale_running_s=900.0, heartbeat_interval_s=20.0, heartbeat_stale_s=120.0, approval_threshold_usd=1.0, jobs_dirname='.nw/jobs'))

Predict the total render seconds for a job as `(p50, p90, confidence)`.

Walks the ETA-key candidates most-specific-first; the first key with
`>= n_min` samples wins (median, with a real or synthesized p90). Falls
back through the coarse key, the output-kind key, then the cold prior. An
all-cache-hit plan short-circuits to `cache_hit_floor_s` (`"exact"`).

`units` scales the per-unit answers — a `learned_coarse` hit and the
cold prior — to the job; a specific `learned` key already holds
whole-job samples and is returned as is. `None` means one.

* **Return type:**
  `_Prediction`

### nw.jobs.summarize(project_root, , limit=50, config=JobsConfig(n_min=3, sample_window_k=20, pct_ceil=99, overrun_factor=1.5, cache_hit_floor_s=0.5, dur_buckets_s=(4.0, 8.0, 12.0), prior_total_s={'image': 12.0, 'video': 90.0, 'audio': 15.0}, default_prior_total_s=30.0, stale_running_s=900.0, heartbeat_interval_s=20.0, heartbeat_stale_s=120.0, approval_threshold_usd=1.0, jobs_dirname='.nw/jobs'))

This project’s jobs **without provisioning it**. Newest first.

[`list_jobs()`](_autosummary/nw.jobs.html.md#nw.jobs.list_jobs) looks like a read and is not. It calls `_runtime()`,
which creates `.nw/jobs/{au,index,durations}` and memoizes a
`_JobsRuntime` — \*which owns a render `ThreadBackend``* — into the
unbounded module-global ``_RUNTIMES`. Measured on a never-rendered
project:

```default
before: .nw/jobs exists? False   _RUNTIMES size: 0
list_jobs(project)  ->  0 rows
after : .nw/jobs exists? True ['au', 'durations', 'index']
        _RUNTIMES size: 1, runtime owns a ThreadBackend
```

A cross-project dashboard polling N projects is exactly the caller that
turns that into render machinery for every project a user has ever glanced
at, held for the life of the process. So this reads what is there and
creates nothing: \*\*no directory, no backend, no `_RUNTIMES` entry\*\*, and
`[]` for a project that has never run a job.

It also performs neither write `_read_job()` does:

- **No reaping.** Marking someone else’s job failed is a write, and a
  read-only fan-out across projects has no business doing it. A job that
  needs reaping will be reaped by a caller that is actually looking at it.
- \*\*No `pct_floor` persistence.\*\* The monotonic floor is a write too.
  Progress may therefore appear to tick backwards *within this view* if a
  prediction lengthens; that is the honest cost of not writing, and the
  per-project view still holds the floor.

Status still comes from the au store, never from the index record’s own
`status` field: `FileSystemStore` synthesizes `PENDING` for a missing
key, so membership lives in the index while *outcome* lives in the store,
and trusting the index’s copy reports finished jobs as queued.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`Job`](_autosummary/nw.jobs.html.md#nw.jobs.Job)]

### nw.jobs.to_dict(job)

Serialize a [`Job`](_autosummary/nw.jobs.html.md#nw.jobs.Job) to the JSON contract (design report §7.4).

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)


# _autosummary/nw.migrate.html.md

# nw.migrate

Idempotent migration: project.json (sections/shots/refs) → lacing graph.

Pre-graph nw projects (and all the_bells_v\* fixtures from the muvid era)
keep sections, shots, characters, and environments in `project.json` as
arrays. From Phase 3 forward, those live in a per-project lacing annotation
store so reelee can walk the graph for freshness analysis, provenance
queries, and view rendering. The backend is chosen by [`nw.graph_backend`](_autosummary/nw.graph_backend.html.md#module-nw.graph_backend)
— a per-project `SqliteStore` file by default, or a shared Postgres DB when
`NW_GRAPH_BACKEND=postgres` (Phase 4, reelee#177).

This module’s job is to bridge the two formats \*without losing data and
without requiring the user to do anything\*. [`migrate_to_graph()`](_autosummary/nw.migrate.html.md#nw.migrate.migrate_to_graph):

- Is idempotent — running it twice is a no-op.
- Only writes the graph; the original `project.json` is left in place
  (and trimmed of fields the graph now owns, with the original kept under
  `.nw/project.json.pre-graph.bak` so a downgrade is possible).
- Marks completion via a `.nw/migrated_to_graph` sentinel file.

Project-level metadata (title, song, global_style, notes, schema_version)
stays in `project.json`. Sections, shots, characters, environments,
decisions move into the graph.

### Functions

| [`is_migrated`](_autosummary/nw.migrate.html.md#nw.migrate.is_migrated)(project_root)                         | True iff this project has been migrated to the lacing graph.             |
|----------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------|
| [`migrate_to_graph`](_autosummary/nw.migrate.html.md#nw.migrate.migrate_to_graph)(project_root, \*[, backup, ...]) | Migrate `project_root`'s project.json into the lacing graph.             |
| [`open_project_graph`](_autosummary/nw.migrate.html.md#nw.migrate.open_project_graph)(project_root)                  | Open (and create on first call) the project's graph store, with tiers.   |
| [`open_project_graph_readonly`](_autosummary/nw.migrate.html.md#nw.migrate.open_project_graph_readonly)(project_root)         | Open the project's graph store to **read**, without taking a write lock. |
| [`project_asset_id`](_autosummary/nw.migrate.html.md#nw.migrate.project_asset_id)(project_root)                    | The asset_id used as the project's graph anchor.                         |
| `project_graph_db_path`(project_root)                                                              |                                                                          |

### nw.migrate.is_migrated(project_root)

True iff this project has been migrated to the lacing graph.

* **Return type:**
  [`bool`](https://docs.python.org/3/builtins/functions.html#bool)

### nw.migrate.migrate_to_graph(project_root, , backup=True, was_attributed_to='agent:nw.migrate')

Migrate `project_root`’s project.json into the lacing graph.

Idempotent: returns `{"already_migrated": 1, ...}` with zero writes if
the sentinel exists. Otherwise reads `project.json`, writes equivalent
annotations into `project.annot.sqlite`, drops the migrated arrays from
`project.json`, and writes the sentinel.

* **Parameters:**
  * **project_root** ([`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)) – Path to a project root.
  * **backup** ([`bool`](https://docs.python.org/3/builtins/functions.html#bool)) – When True (default), copy the original `project.json` to
    `.nw/project.json.pre-graph.bak` before trimming.
  * **was_attributed_to** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str)) – Provenance for the migrator. Defaults to
    `"agent:nw.migrate"`; pass `"user:<handle>"` from a CLI.
* **Returns:**
  ```
  ``
  ```

  {“sections”: N, “shots”: N, “characters”: N, “environments”: N,
  : ”decisions”: N}\`\`.
* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`int`](https://docs.python.org/3/builtins/functions.html#int)]

### nw.migrate.open_project_graph(project_root)

Open (and create on first call) the project’s graph store, with tiers.

The backend (SQLite file by default, or a shared Postgres DB when
`NW_GRAPH_BACKEND=postgres`) is resolved by [`nw.graph_backend`](_autosummary/nw.graph_backend.html.md#module-nw.graph_backend) — the
single config-driven seam. Callers get an `IntervalAnnotationStore` and
never learn which backend answered.

* **Return type:**
  `IntervalAnnotationStore`

### nw.migrate.open_project_graph_readonly(project_root)

Open the project’s graph store to **read**, without taking a write lock.

[`open_project_graph()`](_autosummary/nw.migrate.html.md#nw.migrate.open_project_graph) calls `_ensure_tiers()`, which issues an
`add_tier` per project tier on *every* open. That is a write, so a reader
contended with any live writer — and on SQLite it does not merely wait.
Measured with a writer holding `BEGIN IMMEDIATE`:

| `SqliteStore(path)`        | OK in **0.19 ms**                  |
|----------------------------|------------------------------------|
| `open_project_graph(root)` | `OperationalError` after **5.4 s** |

**This read does not merely block — it raises**, and it raises slowly
enough to be mistaken for a hang and chased in the wrong layer. Phrasing
it as efficiency would invite a revert; phrasing it as blocking would
invite a busy-timeout tweak. The failure is categorical.

That matters the moment anything fans out across projects while a render
is running. Measured through the consumer that motivated it — listing
sibling projects, each of which reads a genre envelope — one contended
project cost **5429 ms** and *then* lost its metadata, because the caller
catches per project. Through this path the same call is **1.33 ms** and
keeps it.

Two deliberate differences from the read-write open, both following from
“a read does not write”:

- **No tier creation.** The store is opened as it is on disk. A tier that
  is missing simply has no annotations to return, which is the honest
  answer to a reader.
- **No migration.** [`open_project_graph()`](_autosummary/nw.migrate.html.md#nw.migrate.open_project_graph) passes `migrate=True` so a
  file written under an older lacing schema upgrades on open. Upgrading is
  a write, and a surface that silently migrated every project it *listed*
  would be the same class of defect as one that created metadata for them.
  `ProjectGraph` falls back to the read-write open for such a file, so
  the rare case pays the lock and the common case does not.

In Postgres mode this delegates to the ordinary open: MVCC readers do not
block writers, so the problem being solved is SQLite-specific and inventing
a second path there would add a seam with nothing behind it.

* **Raises:**
  [**FileNotFoundError**](https://docs.python.org/3/builtins/exceptions.html#FileNotFoundError) – in SQLite mode, when the project has no graph
      database yet. Creating one is a write; a reader is told plainly
      that there is nothing to read rather than quietly making it.
* **Return type:**
  `IntervalAnnotationStore`

### nw.migrate.project_asset_id(project_root)

The asset_id used as the project’s graph anchor.

For projects with a song registered in project.json, this is the SHA-256
of the song bytes. Otherwise a stable fallback derived from the title.

Mirrors [`nw.storyboard.project_asset_id()`](_autosummary/nw.storyboard.html.md#nw.storyboard.project_asset_id) so the project graph and
the storyboard share an asset_id.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)


# _autosummary/nw.pricing.html.md

# nw.pricing

Re-quoting a persisted plan at today’s rates (nw#74).

nw writes cost figures into places that are **read back later**: a
render-decision payload, a `RenderResultBodyV1` body, a job’s cost gate.
Each of those numbers is a `falaw.CallPlan.estimated_cost_usd` frozen at
plan time. falaw’s rate tables move — 0.0.46 re-quoted every premium LLM call
*tenfold upward* — so a figure nw stored before a table moves under-quotes the
run it is later used to describe or gate. Under-quoting is the one direction a
spend decision must never err in.

falaw 0.0.49 (falaw#60) supplies the two halves of the fix:
`falaw.CostBasis` (which pricer, what it priced, the quantity hints,
the rate table and its content digest) and `falaw.reprice_plan()` (pure
data: no network, no billing API, no cache peek). This module is nw’s single
adoption point for them, and it enforces one rule:

**A stored figure is never reported as current.** Reading a persisted cost
means calling [`current_quote()`](_autosummary/nw.pricing.html.md#nw.pricing.current_quote) first and reporting
[`PlanQuote.total_usd`](_autosummary/nw.pricing.html.md#nw.pricing.PlanQuote.total_usd) with [`PlanQuote.status`](_autosummary/nw.pricing.html.md#nw.pricing.PlanQuote.status) beside it. A plan
whose calls carry no basis re-prices to `None` — *unknown*, never its stale
number, and never zero.

Two things this module deliberately does **not** do:

- **It does not touch cache identity.** `cost_basis` is descriptive: falaw
  omits it from the serialized call when unset and keeps it out of
  `plan_hash` and the per-call cache key. So a resumed render still dedups
  on the same digest, and [`nw.jobs.enqueue()`](_autosummary/nw.jobs.html.md#nw.jobs.enqueue)’s idempotency key is unmoved
  by anything here — `tests/test_pricing.py` pins that.
- **It does not re-quote money already spent.**
  [`nw.Project.total_spend_usd()`](_autosummary/nw.html.md#nw.Project.total_spend_usd) sums what was *billed*, and a receipt is
  > not a quote: re-pricing it at today’s rates would rewrite history. Its
  > estimate-based fallback is an as-of figure and says so.

```pycon
>>> from falaw import CallPlan, Plan
>>> stale = CallPlan(tool="text_to_video", application="fal-ai/x",
...                  arguments={}, output_kind="video",
...                  estimated_cost_usd=0.25)
>>> quote = current_quote(Plan(calls=(stale,)))
>>> quote.status, quote.total_usd, quote.as_of_total_usd
('unknown', None, 0.25)
```

### Module Attributes

| [`QuoteStatus`](_autosummary/nw.pricing.html.md#nw.pricing.QuoteStatus)                 | What a re-quote was able to say about a whole plan.                           |
|------------------------------------------------------------------------------|-------------------------------------------------------------------------------|
| [`TOTAL_AGREEMENT_ABS_TOL_USD`](_autosummary/nw.pricing.html.md#nw.pricing.TOTAL_AGREEMENT_ABS_TOL_USD) | How far a payload's stored total may sit from its calls' sum and still agree. |
| [`DISAGREEING_TOTAL_REASON`](_autosummary/nw.pricing.html.md#nw.pricing.DISAGREEING_TOTAL_REASON)    | Why a payload with an internally inconsistent total re-prices as unknown.     |

### Functions

| [`cost_records`](_autosummary/nw.pricing.html.md#nw.pricing.cost_records)(plan)                              | The JSON-able per-call cost rows nw persists in a decision payload.                                           |
|--------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------|
| [`current_quote`](_autosummary/nw.pricing.html.md#nw.pricing.current_quote)(plan, \*[, pricers])              | Re-quote `plan` at today's rates and report the result honestly.                                              |
| [`plan_from_cost_records`](_autosummary/nw.pricing.html.md#nw.pricing.plan_from_cost_records)(records)                 | Rebuild a re-quotable `falaw.Plan` from [`cost_records()`](_autosummary/nw.pricing.html.md#nw.pricing.cost_records) rows. |
| [`quote_from_cost_records`](_autosummary/nw.pricing.html.md#nw.pricing.quote_from_cost_records)(records, \*[, pricers]) | Today's price for the calls stored in a decision payload.                                                     |
| [`quote_render_decision`](_autosummary/nw.pricing.html.md#nw.pricing.quote_render_decision)(payload, \*[, pricers])   | Today's price for a `render_shot` decision payload.                                                           |
| [`unquotable`](_autosummary/nw.pricing.html.md#nw.pricing.unquotable)(reason)                              | A quote for something that could not be re-quoted at all.                                                     |

### Classes

| [`PlanQuote`](_autosummary/nw.pricing.html.md#nw.pricing.PlanQuote)(\*, total_usd, status, ...[, reason])   | Today's price for a persisted plan, with the stale figure alongside.   |
|----------------------------------------------------------------------------------------------------|------------------------------------------------------------------------|

### nw.pricing.DISAGREEING_TOTAL_REASON *= "the payload's total_estimated_cost_usd does not match the sum of its calls, so neither figure can be trusted as this render's price"*

Why a payload with an internally inconsistent total re-prices as unknown.

### *class* nw.pricing.PlanQuote(, total_usd, status, as_of_total_usd, repriced, reason='')

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

Today’s price for a persisted plan, with the stale figure alongside.

The stale figure is kept — as [`as_of_total_usd`](_autosummary/nw.pricing.html.md#nw.pricing.PlanQuote.as_of_total_usd), explicitly named
“as of then” — because an audit surface wants to show the movement. What
it must never do is *present* it as current; that is what
[`total_usd`](_autosummary/nw.pricing.html.md#nw.pricing.PlanQuote.total_usd) and [`status`](_autosummary/nw.pricing.html.md#nw.pricing.PlanQuote.status) are for.

#### as_of_total_usd *: [float](https://docs.python.org/3/builtins/functions.html#float) | [None](https://docs.python.org/3/builtins/constants.html#None)*

What the persisted plan said, `None` if it already said unknown.

A fact about the moment it was written. Render it labelled as such, or
not at all.

#### *property* basis_changed *: [bool](https://docs.python.org/3/builtins/functions.html#bool)*

True when a rate table moved underneath at least one call.

The audit answer the frozen number could never give: it separates “the
price changed because the *table* changed” from “the price changed
because the plan did”. Read it beside [`status`](_autosummary/nw.pricing.html.md#nw.pricing.PlanQuote.status) — a `changed`
with this `False` is a caller quoting different quantities, not a
repricing event.

#### *property* delta_usd *: [float](https://docs.python.org/3/builtins/functions.html#float) | [None](https://docs.python.org/3/builtins/constants.html#None)*

`total_usd - as_of_total_usd`, or `None` when either is unknown.

`None` rather than `0.0`: a plan that lost its price did not move
by zero.

#### *property* has_unknown_costs *: [bool](https://docs.python.org/3/builtins/functions.html#bool)*

True when the total cannot be known — the gate’s refusal condition.

The same judgement as `falaw.Plan.has_unknown_costs`, made at
re-quote time rather than at plan time.

#### reason *: [str](https://docs.python.org/3/builtins/stdtypes.html#str)*

Why the *whole* quote is unknown, when that is the situation.

Set by [`unquotable()`](_autosummary/nw.pricing.html.md#nw.pricing.unquotable) (the thing handed in was not a plan) and by
[`quote_render_decision()`](_autosummary/nw.pricing.html.md#nw.pricing.quote_render_decision) (the payload contradicts itself). Empty for
an ordinary re-quote, where the per-call reasons live on
[`repriced`](_autosummary/nw.pricing.html.md#nw.pricing.PlanQuote.repriced) instead.

#### repriced *: RepricedPlan*

falaw’s per-call diff — `status`, `basis_changed`, `reason` per
call. Read it for the audit view; [`total_usd`](_autosummary/nw.pricing.html.md#nw.pricing.PlanQuote.total_usd) is the headline.

#### status *: [Literal](https://docs.python.org/3/library/typing.html#typing.Literal)['unchanged', 'changed', 'unknown']*

Which of the three cases this plan fell into — see [`QuoteStatus`](_autosummary/nw.pricing.html.md#nw.pricing.QuoteStatus).

#### to_dict()

JSON-able headline for a surface (an API response, a job record).

Deliberately not the whole per-call diff: a spend surface needs the
number, whether it is knowable, and whether it moved. Reach into
[`repriced`](_autosummary/nw.pricing.html.md#nw.pricing.PlanQuote.repriced) for the rest.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]

#### total_usd *: [float](https://docs.python.org/3/builtins/functions.html#float) | [None](https://docs.python.org/3/builtins/constants.html#None)*

Today’s billable total, or `None` when any billable call is
unpriceable today. `None` means unknown, never free (nw invariant #2).

### nw.pricing.QuoteStatus

What a re-quote was able to say about a whole plan.

- `"unchanged"` — every billable call re-quoted, and the total did not move.
- `"changed"` — re-quoted, and at least one call’s price moved. The total in
  [`PlanQuote.total_usd`](_autosummary/nw.pricing.html.md#nw.pricing.PlanQuote.total_usd) is today’s; the stored one was yesterday’s.
- `"unknown"` — at least one billable call could not be re-quoted (no basis,
  no pricer, a model that left the catalogue, a table that moved out from
  under it). [`PlanQuote.total_usd`](_autosummary/nw.pricing.html.md#nw.pricing.PlanQuote.total_usd) is `None`: unknown, never free, and
  never the frozen figure.

alias of [`Literal`](https://docs.python.org/3/library/typing.html#typing.Literal)[‘unchanged’, ‘changed’, ‘unknown’]

### nw.pricing.TOTAL_AGREEMENT_ABS_TOL_USD *= 1e-09*

How far a payload’s stored total may sit from its calls’ sum and still agree.

Floating-point slack on a sum of a handful of costs, nothing more — far below
the smallest sub-cent figure any rate table quotes, so it can never absorb a
real disagreement.

### nw.pricing.cost_records(plan)

The JSON-able per-call cost rows nw persists in a decision payload.

A serialized call cannot be re-quoted from `application` and
`arguments` alone — the quantity hints that priced it are estimator-only
and never reach the wire arguments. Carrying `cost_basis` alongside the
frozen figure is what makes the row re-quotable later by
[`quote_from_cost_records()`](_autosummary/nw.pricing.html.md#nw.pricing.quote_from_cost_records).

`cost_basis` is omitted when unset, exactly as falaw omits it, so a row
written by a caller that records no basis is byte-identical to what nw
wrote before nw#74.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]]

```pycon
>>> from falaw import CallPlan, Plan
>>> call = CallPlan(tool="t", application="a", arguments={},
...                 output_kind="video", estimated_cost_usd=1.0)
>>> sorted(cost_records(Plan(calls=(call,)))[0])
['application', 'cache_status', 'estimated_cost_usd', 'tool']
```

### nw.pricing.current_quote(plan, \*, pricers={'llm_rates': Pricer(quote=<function \_quote_from_llm_rates>, table='falaw/data/llm_rates.json', version=<functools._lru_cache_wrapper object>), 'model_catalogue': Pricer(quote = <function \_quote_from_catalogue>, table='falaw/data/models.json', version=<functools._lru_cache_wrapper object>)})

Re-quote `plan` at today’s rates and report the result honestly.

Pure data — `falaw.reprice_plan()` reads the committed rate tables and
does arithmetic. No network, no billing API, no cache peek, so this is
safe to call anywhere a `plan()` is (nw invariant #1).

* **Parameters:**
  * **plan** (`Plan`) – The plan to re-quote — typically one just rebuilt from a stored
    payload with [`plan_from_cost_records()`](_autosummary/nw.pricing.html.md#nw.pricing.plan_from_cost_records).
  * **pricers** ([`Mapping`](https://docs.python.org/3/library/typing.html#typing.Mapping)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), `Pricer`]) – Pricing rules by `falaw.CostBasis.pricer`. The seam for
    a caller with reconciled numbers of their own; see
    `falaw.reprice.Pricer`.
* **Return type:**
  [`PlanQuote`](_autosummary/nw.pricing.html.md#nw.pricing.PlanQuote)

```pycon
>>> from falaw import Plan
>>> current_quote(Plan(calls=())).status
'unchanged'
```

### nw.pricing.plan_from_cost_records(records)

Rebuild a re-quotable `falaw.Plan` from [`cost_records()`](_autosummary/nw.pricing.html.md#nw.pricing.cost_records) rows.

The result is a **pricing** plan, not an executable one: `arguments` is
empty and `output_kind` is a placeholder, because a stored cost row does
not carry the wire payload and re-pricing does not read it. Never hand one
of these to `falaw.execute_plan()` — build a fresh plan for that.

Rows missing `cost_basis` come back basis-free, which is exactly what
makes them re-price as `"no_basis"` (unknown) rather than as their
frozen number.

* **Return type:**
  `Plan`

### nw.pricing.quote_from_cost_records(records, \*, pricers={'llm_rates': Pricer(quote=<function \_quote_from_llm_rates>, table='falaw/data/llm_rates.json', version=<functools._lru_cache_wrapper object>), 'model_catalogue': Pricer(quote = <function \_quote_from_catalogue>, table='falaw/data/models.json', version=<functools._lru_cache_wrapper object>)})

Today’s price for the calls stored in a decision payload.

The read-back half of [`cost_records()`](_autosummary/nw.pricing.html.md#nw.pricing.cost_records). `None` or a non-sequence
(a payload that recorded no calls at all) yields an empty plan’s quote —
`total_usd == 0.0`, `status == "unchanged"` — because “no calls” is a
known zero, not an unknown.

* **Return type:**
  [`PlanQuote`](_autosummary/nw.pricing.html.md#nw.pricing.PlanQuote)

### nw.pricing.quote_render_decision(payload, \*, pricers={'llm_rates': Pricer(quote=<function \_quote_from_llm_rates>, table='falaw/data/llm_rates.json', version=<functools._lru_cache_wrapper object>), 'model_catalogue': Pricer(quote = <function \_quote_from_catalogue>, table='falaw/data/models.json', version=<functools._lru_cache_wrapper object>)})

Today’s price for a `render_shot` decision payload.

The counterpart to what `nw.workflow._record_render_decision()` wrote.
Read this — never `payload["total_estimated_cost_usd"]` — whenever a
stored render cost is about to be shown or gated on as a *current* figure.

A payload written before nw#74 carries no per-call basis, so it re-quotes
as `"unknown"` with `total_usd` `None`. That is the point: nobody
can say what it costs today, and saying so is better than repeating a
number that has since moved.

The stored `total_estimated_cost_usd` is the payload’s own headline, so
it — not the calls’ sum — is reported as [`PlanQuote.as_of_total_usd`](_autosummary/nw.pricing.html.md#nw.pricing.PlanQuote.as_of_total_usd).
When the two **disagree**, the whole payload is *unknown*: a total of $3
over a payload whose calls sum to $0 is a broken record, and answering
“$0, unchanged” would report a stored $3 as a known zero. nw’s own writer
never produces such a payload; a hand-edited or truncated one can, and
unknown is the only honest reading of it.

* **Return type:**
  [`PlanQuote`](_autosummary/nw.pricing.html.md#nw.pricing.PlanQuote)

```pycon
>>> broken = quote_render_decision(
...     {"calls": [], "total_estimated_cost_usd": 3.0})
>>> broken.status, broken.total_usd, broken.as_of_total_usd
('unknown', None, 3.0)
>>> stale = quote_render_decision(
...     {"calls": [{"tool": "t", "application": "a",
...                 "estimated_cost_usd": 3.0}],
...      "total_estimated_cost_usd": 3.0})
>>> stale.status, stale.total_usd, stale.as_of_total_usd
('unknown', None, 3.0)
```

### nw.pricing.unquotable(reason)

A quote for something that could not be re-quoted at all.

For the caller who was handed a *plan-shaped* thing that turned out not to
be a plan — an unparseable payload, an object of the wrong type. The honest
answer is `None` (unknown), not the frozen figure that came with it, and
not `0.0`.

[`PlanQuote.repriced`](_autosummary/nw.pricing.html.md#nw.pricing.PlanQuote.repriced) is an empty `falaw.RepricedPlan`: there
were no calls to diff. Read [`PlanQuote.reason`](_autosummary/nw.pricing.html.md#nw.pricing.PlanQuote.reason) for what went wrong.

* **Return type:**
  [`PlanQuote`](_autosummary/nw.pricing.html.md#nw.pricing.PlanQuote)

```pycon
>>> q = unquotable("params['plan'] is not a falaw Plan")
>>> q.status, q.total_usd, q.has_unknown_costs
('unknown', None, True)
```


# _autosummary/nw.project.html.md

# nw.project

Project facade: a folder on disk → typed reads, typed writes, typed summary.

A nw project lives at a folder. `project.json` is the SSOT; every other
file is a derived artifact (lyrics, alignment store, character cards, shot
output videos, etc.).

The facade is deliberately small. It exposes:

- read/write/update of the `ProjectSpec`,
- folder helpers (`character_dir()`, `environment_dir()`,
  `shot_dir()`),
- the setter operations the muvid_project agent had to express via
  `python -c` glue (`set_title`, `set_global_style`,
  `set_character_anchor`, `list_character_images`),
- a typed `read_summary()` that returns the facts `muvid status`
  printed,
- a `log_decision()` append-only line writer.

It does NOT do rendering — that’s [`nw.workflow`](_autosummary/nw.workflow.html.md#module-nw.workflow) (Phase 1b.2).

### Classes

| [`CharacterImage`](_autosummary/nw.project.html.md#nw.project.CharacterImage)(path, \*[, from_ref, ...])   | One image associated with a character.   |
|----------------------------------------------------------------------------------------------|------------------------------------------|
| [`Project`](_autosummary/nw.project.html.md#nw.project.Project)(root, \*[, auto_migrate])           | A folder-backed nw project.              |

### *class* nw.project.CharacterImage(path, , from_ref=False, from_selected=False, is_anchor=False)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

One image associated with a character.

Returned by [`Project.list_character_images()`](_autosummary/nw.project.html.md#nw.project.Project.list_character_images). Distinguishes:

- `from_ref`: file lives under `characters/<name>/refs/` — a candidate
  from generation or upload.
- `from_selected`: under `characters/<name>/selected/` — curator-picked.
- `is_anchor`: this is the file the character card currently points at as
  the “use this image” anchor (lipsync seed, etc.).

### *class* nw.project.Project(root, , auto_migrate=True)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

A folder-backed nw project.

Construct from a path (must exist + must contain `project.json`); use
[`Project.init()`](_autosummary/nw.project.html.md#nw.project.Project.init) to bootstrap a new project on disk.

#### add_character(name, , description='')

Add a character (idempotent: re-adds update the description).

Re-adding updates *only* the description: any stable attributes
already recorded on the character (costume, palette anchors,
`do_not_do` …) are carried over, so calling this again is not a
way to lose them.

* **Return type:**
  [`CharacterRef`](_autosummary/nw.schema.html.md#nw.schema.CharacterRef)

#### *classmethod* init(root, , title='', song=None, force=False)

Create a new project on disk and return the [`Project`](_autosummary/nw.project.html.md#nw.project.Project) facade.

* **Parameters:**
  * **root** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str) | [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)) – Folder to create. Must not exist (or pass `force=True` to
    overwrite an empty folder).
  * **title** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str)) – Optional human-readable title; defaults to the folder name.
  * **song** (`Union`[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path), [`None`](https://docs.python.org/3/builtins/constants.html#None)]) – Optional path to a master audio file. When given, the file
    is *copied* into `<root>/song/` and registered in the spec.
  * **force** ([`bool`](https://docs.python.org/3/builtins/functions.html#bool)) – When True, accept an existing folder if it’s empty (no
    `project.json`); refuse if a project already exists there.
* **Return type:**
  [`Project`](_autosummary/nw.project.html.md#nw.project.Project)

#### list_character_images(name)

Return all images associated with a character, with provenance flags.

Walks `characters/<name>/refs/` and `characters/<name>/selected/`.
Marks the file the card’s `reference_image_path` points at as
`is_anchor=True`.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`CharacterImage`](_autosummary/nw.project.html.md#nw.project.CharacterImage)]

#### log_decision(kind, \*\*payload)

Record a typed decision in the project graph + the JSONL audit log.

Decisions are project-local provenance: which character anchor was
picked, which model overrode the default, why a shot was retried.
Both surfaces stay in sync:

- The lacing graph (`decision` tier, body schema
  `annot://schema/decision/v1`) is the SSOT — reelee will surface
  these in inspector / network views.
- `.nw/decisions.jsonl` continues as a tail-grep-able audit trail.

* **Return type:**
  [`None`](https://docs.python.org/3/builtins/constants.html#None)

#### read_spec()

Read the project spec, synthesizing from the graph for graph-native fields.

Project-level metadata (title, song, global_style, notes,
schema_version) lives in `project.json`. Sections, shots,
characters, and environments live in the lacing graph and are
synthesized into the returned `ProjectSpec` for back-compat
with code that still reads via `read_spec()`.

* **Return type:**
  [`ProjectSpec`](_autosummary/nw.schema.html.md#nw.schema.ProjectSpec)

#### read_summary()

Return a typed read view of the project — all the facts at once.

* **Return type:**
  [`ProjectSummary`](_autosummary/nw.schema.html.md#nw.schema.ProjectSummary)

#### resolved_genre()

The `{genre, template, params}` envelope this project was created as.

The read accessor for the envelope `nw.genres.initialize_genre()`
persists at creation (nw#32) — same shape as
`nw.genres.resolve_genre()` returns, so consumers reuse or diff
the *effective* creation params without re-deriving them. `None`
for a project with no recorded genre (created before nw#32, or not
through the genre machinery).

* **Return type:**
  [`Optional`](https://docs.python.org/3/library/typing.html#typing.Optional)[[`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)]

#### resumption_brief(, recent=10)

Return a “where we left off” snapshot for the start of a session.

Pure data, fully offline: a decision-log tail, what is reachable
downstream of the last change, recorded spend, and a deterministic
list of suggested next actions. reelee renders it as prose and
injects it as the opening context of a session.

Read [`ResumptionBrief`](_autosummary/nw.schema.html.md#nw.schema.ResumptionBrief) before trusting the numbers —
two of them are upper bounds, and the brief says so in
`caveats` rather than only in a
docstring.

* **Parameters:**
  **recent** ([`int`](https://docs.python.org/3/builtins/functions.html#int)) – How many decision-log entries to include, most recent last.
* **Return type:**
  [`ResumptionBrief`](_autosummary/nw.schema.html.md#nw.schema.ResumptionBrief)

#### set_character_anchor(name, image_path)

Pick an existing image as the character’s anchor (lipsync seed, etc.).

Returns the updated card. Raises if the image isn’t under the
character’s folder, since cross-character anchoring is almost always
a mistake.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]

#### set_global_style(style)

Set the project-level visual style hint.

* **Return type:**
  [`ProjectSpec`](_autosummary/nw.schema.html.md#nw.schema.ProjectSpec)

#### set_song(source, , copy=True)

Register an audio file as the project’s master song.

Probes duration / sample-rate / bitrate via `mixing.audio.Audio` if
available, else leaves them at 0 (the spec accepts the SSOT-only
path with placeholder metadata).

* **Return type:**
  [`SongInfo`](_autosummary/nw.schema.html.md#nw.schema.SongInfo)

#### set_title(title)

Set the project title.

* **Return type:**
  [`ProjectSpec`](_autosummary/nw.schema.html.md#nw.schema.ProjectSpec)

#### total_spend_usd()

Sum the cost recorded on every decision in the project.

Prefers each decision’s *actual* per-artifact `cost_usd` and falls
back to its `total_estimated_cost_usd` when no artifact costs were
recorded.

**Deliberately not re-quoted.** This is money that was *billed*, and a
receipt is not a quote: re-pricing it through
[`nw.pricing.current_quote()`](_autosummary/nw.pricing.html.md#nw.pricing.current_quote) would rewrite history at today’s
rates. The consequence is that the estimate-based fallback is an
as-of-then figure — falaw’s tables have moved since (0.0.46 tenfold
upward on premium LLM calls), so a decision that recorded no artifact
costs contributes what it was quoted then, not what the same render
would cost now. That is the right answer for “what did this project
spend”; it is the wrong one for “what would this cost today”, and
[`nw.pricing.quote_render_decision()`](_autosummary/nw.pricing.html.md#nw.pricing.quote_render_decision) is what answers that (nw#74).

Walks **every store scope** (graph, storyboard, alignment), not just
the project graph: a decision written to the storyboard scope is money
that was spent, and counting only one scope would silently *under*-report
while `caveats` claims an upper bound.

**This is an upper bound on money usefully spent.** A render that was
billed and then failed is recorded exactly like one that succeeded,
because nothing in the execution layer records a per-branch outcome
yet. When failure isolation lands, this should sum over the *produced*
branches only — and this method is the one place that changes.

* **Return type:**
  [`float`](https://docs.python.org/3/builtins/functions.html#float)

#### update_spec(\*\*changes)

Apply field-level changes to the spec; return the new spec.

* **Return type:**
  [`ProjectSpec`](_autosummary/nw.schema.html.md#nw.schema.ProjectSpec)

#### write_spec(spec)

Write the spec.

For back-compat with existing code that builds a `ProjectSpec` and
calls `write_spec`, this routes graph-backed fields (sections,
shots, characters, environments) through the graph and persists the
rest as project.json metadata.

* **Return type:**
  [`None`](https://docs.python.org/3/builtins/constants.html#None)


# _autosummary/nw.renderers.composite_lipsync.html.md

# nw.renderers.composite_lipsync

Strategy: composite_lipsync — character + environment + audio → talking video.

The keystone deliverable from interface_design_plan.md item E. Two-call plan:

1. `composite_character_in_environment` (Flux Kontext): take the character
   anchor and the environment anchor, produce one composited still where the
   character is *in* the environment.
2. `animate_face` (omnihuman): take that composited still + the audio slice,
   produce a lipsynced talking video.

The second call references the first via the `<from 0>` placeholder, so the
Plan is self-contained — caller can inspect `plan.total_cost_usd` before
either call fires.

Failure modes handled at plan time:

- No character anchor → plan() raises with a clear message.
- No environment anchor → plan() raises (composite needs both inputs).

For the case where the user has only a character (no environment), use the
plain `lipsync` strategy, which lipsyncs the character image directly.

model_overrides keys understood:

> - `image_edit` — override the composite model (e.g. flux-pro/kontext/max).
> - `avatar`     — override the lipsync model.

### Classes

| [`CompositeLipsyncStrategy`](_autosummary/nw.renderers.composite_lipsync.html.md#nw.renderers.composite_lipsync.CompositeLipsyncStrategy)()   | `render_strategy="composite_lipsync"`.   |
|-------------------------------------------------------------------------------|------------------------------------------|

### *class* nw.renderers.composite_lipsync.CompositeLipsyncStrategy

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

`render_strategy="composite_lipsync"`.

The “Thor in a bell tower playing piano, lipsynced to the song” strategy.


# _autosummary/nw.renderers.html.md

# nw.renderers

Render strategies — pluggable, plan-producing, **shot-typed**.

**Scope.** A Strategy is the plug-in point of the *shot* render unit
([`nw.workflow`](_autosummary/nw.workflow.html.md#module-nw.workflow)) — and, through the adapter in
`nw/transforms/_adapters/render_strategy.py`, of the **general engine too**.
That adapter wraps *every* registered strategy at import time, so registering
one here publishes a `shot_to_render_result.fal.<name>` Transform for free,
and [`nw.genres.Genre`](_autosummary/nw.html.md#nw.Genre) validates a genre’s `strategy_names` against
this registry. The two registries are one pipeline with two front doors, not
two pipelines — the README says the same thing under “Render strategies”.

What a Strategy **cannot** express is a render that is not a video shot: it is
typed to a [`nw.workflow.ShotPreparation`](_autosummary/nw.workflow.html.md#nw.workflow.ShotPreparation) in and an `output.mp4` out.
So the split is by render *kind*, not by registry:

- a new way to render a **shot** → register a Strategy here, and get the
  Transform adaptation for free;
- a new render **kind** — audio weave, slideshow, anything whose input is not a
  shot — → register a [`nw.transforms.Transform`](_autosummary/nw.html.md#nw.Transform) directly; that registry
  is the render-kind-agnostic one.

See nw#9.

A *strategy* knows how to turn a [`nw.workflow.ShotPreparation`](_autosummary/nw.workflow.html.md#nw.workflow.ShotPreparation) into:

1. A `falaw.Plan` (pure data — [`Strategy.plan()`](_autosummary/nw.renderers.html.md#nw.renderers.Strategy.plan)).
2. A final `output.mp4` path, given the Plan’s executed Artifacts
   ([`Strategy.materialize()`](_autosummary/nw.renderers.html.md#nw.renderers.Strategy.materialize)).

Strategies are registered with an `xdol.Registry` keyed by name. Apps
can register their own strategies (e.g. `composite_lipsync`, `slideshow`,
`panel`) without modifying nw.

Built-in strategies (registered at import):

- `lipsync`            — character anchor + audio → talking video (omnihuman)
- `image_to_video`     — env / fresh storyboard still → animated clip
- `text_to_video`      — prompt-only short clip
- `still`              — image looped over audio (no video gen)
- `composite_lipsync`  — character + environment + audio → composite-then-talk
  : (“Thor in a bell tower playing piano, lipsynced”)

### Module Attributes

| [`strategies`](_autosummary/nw.renderers.html.md#nw.renderers.strategies)   | Public registry — apps add strategies via `strategies.register("name", impl)`.   |
|---------------------------------------------------------------|----------------------------------------------------------------------------------|

### Functions

| [`get_strategy`](_autosummary/nw.renderers.html.md#nw.renderers.get_strategy)(name)            | Look up a strategy by name; raises if unknown.   |
|--------------------------------------------------------------------------------|--------------------------------------------------|
| [`list_strategies`](_autosummary/nw.renderers.html.md#nw.renderers.list_strategies)()             | Return all registered strategy names (sorted).   |
| [`register_strategy`](_autosummary/nw.renderers.html.md#nw.renderers.register_strategy)(name, impl) | Register a strategy.                             |

### Classes

| [`Strategy`](_autosummary/nw.renderers.html.md#nw.renderers.Strategy)(\*args, \*\*kwargs)   | Render-strategy contract.   |
|---------------------------------------------------------------------------------|-----------------------------|

### *class* nw.renderers.Strategy(\*args, \*\*kwargs)

Bases: [`Protocol`](https://docs.python.org/3/library/typing.html#typing.Protocol)

Render-strategy contract.

#### materialize(prep, plan, artifacts)

Turn executed Artifacts into `shot_dir/output.mp4`. May download +
run ffmpeg, but no fal calls.

* **Return type:**
  Path

#### plan(prep, , quality='balanced', model_overrides=None)

Build a `falaw.Plan` for the prepared shot. No fal calls.

* **Return type:**
  `Plan`

### nw.renderers.get_strategy(name)

Look up a strategy by name; raises if unknown.

* **Return type:**
  [`Strategy`](_autosummary/nw.renderers.html.md#nw.renderers.Strategy)

### nw.renderers.list_strategies()

Return all registered strategy names (sorted).

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]

### nw.renderers.register_strategy(name, impl)

Register a strategy. Returns `impl` so it can be used inline.

* **Return type:**
  [`Strategy`](_autosummary/nw.renderers.html.md#nw.renderers.Strategy)

### nw.renderers.strategies *: Registry* *= <Registry nw.renderers>*

Public registry — apps add strategies via `strategies.register("name", impl)`.

### Modules

| [`composite_lipsync`](_autosummary/nw.renderers.composite_lipsync.html.md#module-nw.renderers.composite_lipsync)   | Strategy: composite_lipsync — character + environment + audio → talking video.   |
|------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------|
| [`image_to_video`](_autosummary/nw.renderers.image_to_video.html.md#module-nw.renderers.image_to_video)         | Strategy: image_to_video — env or fresh-storyboard still → animated clip.        |
| [`lipsync`](_autosummary/nw.renderers.lipsync.html.md#module-nw.renderers.lipsync)                       | Strategy: lipsync — character anchor + audio → talking video.                    |
| [`still`](_autosummary/nw.renderers.still.html.md#module-nw.renderers.still)                           | Strategy: still — image looped over audio (no fal video gen).                    |
| [`text_to_video`](_autosummary/nw.renderers.text_to_video.html.md#module-nw.renderers.text_to_video)           | Strategy: text_to_video — prompt-only short clip.                                |


# _autosummary/nw.renderers.image_to_video.html.md

# nw.renderers.image_to_video

Strategy: image_to_video — env or fresh-storyboard still → animated clip.

Two-call workflow:

1. If the shot has an environment anchor, use it as the i2v seed (no image
   gen call needed). Otherwise, generate a fresh storyboard still via
   `falaw.generate_image`.
2. Animate the seed with `falaw.image_to_video`.

A future `seed` parameter (see interface_design_plan item D) will let
callers force the character anchor as the seed. For now, env > fresh-still.

model_overrides keys understood:

> - `image`          — image-gen model when generating a fresh still.
> - `image_to_video` — i2v model (e.g. hailuo, kling, seedance).

### Classes

| [`ImageToVideoStrategy`](_autosummary/nw.renderers.image_to_video.html.md#nw.renderers.image_to_video.ImageToVideoStrategy)()   | `render_strategy="image_to_video"`.   |
|---------------------------------------------------------------------------|---------------------------------------|

### *class* nw.renderers.image_to_video.ImageToVideoStrategy

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

`render_strategy="image_to_video"`.


# _autosummary/nw.renderers.lipsync.html.md

# nw.renderers.lipsync

Strategy: lipsync — character anchor + audio → talking video.

Calls `falaw.animate_face` (image+audio → talking video). Defaults to
`omnihuman/v1.5` at `quality="high"` because the default `ai-avatar`
hangs reliably (see muvid_project bugs_encountered.md, 2026-05-07).

If multiple characters are present, the first one is picked and a warning is
emitted — multi-character lipsync is composite_lipsync’s territory (Phase 2).

model_overrides keys understood:

> - `avatar` — override the `avatar_model_id` (e.g. omnihuman, ai-avatar).

### Classes

| [`LipsyncStrategy`](_autosummary/nw.renderers.lipsync.html.md#nw.renderers.lipsync.LipsyncStrategy)()   | `render_strategy="lipsync"`.   |
|----------------------------------------------------------------------|--------------------------------|

### *class* nw.renderers.lipsync.LipsyncStrategy

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

`render_strategy="lipsync"`.


# _autosummary/nw.renderers.still.html.md

# nw.renderers.still

Strategy: still — image looped over audio (no fal video gen).

Two paths:

- If the shot has an environment anchor or a character anchor on disk, no
  image-gen call is needed. The strategy returns a Plan with **zero** fal
  calls (cost = 0); `materialize()` just runs ffmpeg locally to loop
  the image over the audio slice.
- If neither anchor is set, plans one `generate_image` call to make a
  fresh storyboard still, then loops it locally.

This is the cheapest strategy — useful for sections where motion would be
distracting, or for placeholder rendering during development.

model_overrides keys understood:

> - `image` — image-gen model when generating a fresh still.

### Classes

| [`StillStrategy`](_autosummary/nw.renderers.still.html.md#nw.renderers.still.StillStrategy)()   | `render_strategy="still"`.   |
|--------------------------------------------------------------------|------------------------------|

### *class* nw.renderers.still.StillStrategy

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

`render_strategy="still"`.


# _autosummary/nw.renderers.text_to_video.html.md

# nw.renderers.text_to_video

Strategy: text_to_video — prompt-only short clip.

Single-call: `falaw.text_to_video`. No image inputs.

model_overrides keys understood:

> - `text_to_video` — t2v model (e.g. seedance, veo3).

### Classes

| [`TextToVideoStrategy`](_autosummary/nw.renderers.text_to_video.html.md#nw.renderers.text_to_video.TextToVideoStrategy)()   | `render_strategy="text_to_video"`.   |
|--------------------------------------------------------------------------|--------------------------------------|

### *class* nw.renderers.text_to_video.TextToVideoStrategy

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

`render_strategy="text_to_video"`.


# _autosummary/nw.schema.html.md

# nw.schema

Schema for an nw project — narrative-workflow SSOT data shapes.

A project is a folder with a `project.json` at its root. The shape is
deliberately compatible with the layout muvid established for music-video
projects, so the_bells_v\* fixtures load directly into nw without
migration. nw generalizes muvid’s IR by:

- making `RenderStrategy` open (a string), so apps can register
  their own strategies (composite_lipsync, slideshow, panel, …) without
  touching nw,
- adding [`ProjectSummary`](_autosummary/nw.schema.html.md#nw.schema.ProjectSummary) as a typed read view returned by
  `Project.read_summary()`,
- promoting setters that muvid expressed via `python -c` glue
  (`set_title`, `set_global_style`, `set_character_anchor`).

Pydantic is used (instead of frozen dataclasses) for two reasons:

1. lacing already uses Pydantic — sharing the conventions keeps the
   ecosystem coherent.
2. nw will eventually round-trip schemas through HTTP/MCP; Pydantic gives
   JSON-Schema export and validation for free.

### Classes

| [`CharacterRef`](_autosummary/nw.schema.html.md#nw.schema.CharacterRef)(\*\*data)    | Pointer to a character folder under `characters/<name>/`.                                                                                               |
|----------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------|
| [`DecisionEntry`](_autosummary/nw.schema.html.md#nw.schema.DecisionEntry)(\*\*data)   | One entry of a project's decision log, flattened for display.                                                                                           |
| [`EnvironmentRef`](_autosummary/nw.schema.html.md#nw.schema.EnvironmentRef)(\*\*data)  | Pointer to an environment folder under `environments/<name>/`.                                                                                          |
| [`ProjectSpec`](_autosummary/nw.schema.html.md#nw.schema.ProjectSpec)(\*\*data)     | The top-level project SSOT, persisted as `project.json`.                                                                                                |
| [`ProjectSummary`](_autosummary/nw.schema.html.md#nw.schema.ProjectSummary)(\*\*data)  | Typed read view of a project — what `muvid status` printed, but typed.                                                                                  |
| [`ResumptionBrief`](_autosummary/nw.schema.html.md#nw.schema.ResumptionBrief)(\*\*data) | A "where we left off" snapshot, returned by [`nw.Project.resumption_brief()`](_autosummary/nw.html.md#nw.Project.resumption_brief). |
| [`SectionSpec`](_autosummary/nw.schema.html.md#nw.schema.SectionSpec)(\*\*data)     | A non-overlapping span of the project's master timeline.                                                                                                |
| [`ShotSpec`](_autosummary/nw.schema.html.md#nw.schema.ShotSpec)(\*\*data)        | A timeline-locked visual unit.                                                                                                                          |
| [`SongInfo`](_autosummary/nw.schema.html.md#nw.schema.SongInfo)(\*\*data)        | Metadata for the master audio file.                                                                                                                     |

### *class* nw.schema.CharacterRef(\*\*data)

Bases: `BaseModel`

Pointer to a character folder under `characters/<name>/`.

The stable-attribute fields mirror
[`nw.bodies.CharacterRefBodyV1`](_autosummary/nw.bodies.html.md#nw.bodies.CharacterRefBodyV1) field-for-field, and that is
load-bearing rather than cosmetic: [`nw.Project.read_spec()`](_autosummary/nw.html.md#nw.Project.read_spec) builds
a `CharacterRef` from the graph body and
[`nw.Project.write_spec()`](_autosummary/nw.html.md#nw.Project.write_spec) writes the body back from the
`CharacterRef`. Any field present on the body but missing here is
**silently erased** by the next `update_spec` — which is what used to
happen to `reference_image_urls`. Add a field to one, add it to both.

#### model_config *: [ClassVar](https://docs.python.org/3/library/typing.html#typing.ClassVar)[ConfigDict]* *= {'extra': 'ignore'}*

Configuration for the model, should be a dictionary conforming to [`ConfigDict`][pydantic.config.ConfigDict].

### *class* nw.schema.DecisionEntry(\*\*data)

Bases: `BaseModel`

One entry of a project’s decision log, flattened for display.

#### model_config *: [ClassVar](https://docs.python.org/3/library/typing.html#typing.ClassVar)[ConfigDict]* *= {'extra': 'ignore'}*

Configuration for the model, should be a dictionary conforming to [`ConfigDict`][pydantic.config.ConfigDict].

### *class* nw.schema.EnvironmentRef(\*\*data)

Bases: `BaseModel`

Pointer to an environment folder under `environments/<name>/`.

Mirrors [`nw.bodies.EnvironmentRefBodyV1`](_autosummary/nw.bodies.html.md#nw.bodies.EnvironmentRefBodyV1) field-for-field, for the
same load-bearing reason as [`CharacterRef`](_autosummary/nw.schema.html.md#nw.schema.CharacterRef) — see that docstring.
`reference_image_urls` (the lookbook the FE curates for a *location*)
was erased by every `update_spec` until this mirror was completed.

#### model_config *: [ClassVar](https://docs.python.org/3/library/typing.html#typing.ClassVar)[ConfigDict]* *= {'extra': 'ignore'}*

Configuration for the model, should be a dictionary conforming to [`ConfigDict`][pydantic.config.ConfigDict].

### *class* nw.schema.ProjectSpec(\*\*data)

Bases: `BaseModel`

The top-level project SSOT, persisted as `project.json`.

Field names and order are chosen to round-trip identically with muvid’s
ProjectSpec for `schema_version=1`, so the_bells_v\* fixtures (and any
other muvid-shaped project) load and re-save without churn.

#### model_config *: [ClassVar](https://docs.python.org/3/library/typing.html#typing.ClassVar)[ConfigDict]* *= {'extra': 'ignore'}*

Configuration for the model, should be a dictionary conforming to [`ConfigDict`][pydantic.config.ConfigDict].

### *class* nw.schema.ProjectSummary(\*\*data)

Bases: `BaseModel`

Typed read view of a project — what `muvid status` printed, but typed.

Returned by `Project.read_summary()`. Holds the small facts the user
most often wants: title, root, song path, counts of characters / shots /
sections / output, plus a coarse “stages_done” list naming the lifecycle
stages that have been reached.

#### model_config *: [ClassVar](https://docs.python.org/3/library/typing.html#typing.ClassVar)[ConfigDict]* *= {'extra': 'ignore'}*

Configuration for the model, should be a dictionary conforming to [`ConfigDict`][pydantic.config.ConfigDict].

#### *property* stages_done *: [list](https://docs.python.org/3/builtins/stdtypes.html#list)[[str](https://docs.python.org/3/builtins/stdtypes.html#str)]*

Coarse stage list — what’s been reached, in lifecycle order.

### *class* nw.schema.ResumptionBrief(\*\*data)

Bases: `BaseModel`

A “where we left off” snapshot, returned by [`nw.Project.resumption_brief()`](_autosummary/nw.html.md#nw.Project.resumption_brief).

Pure data: no fal calls, no LLM, no network. reelee renders it as prose
and injects it as the first tool-result of a session.

\*\*The field names are chosen to be honest about what nw can currently
measure\*\*, because a confidently wrong number is worse than no number:

- `downstream_of_last_authored_change` is *not* “stale”. It is
  `nw.descendants_of` — pure provenance reachability, comparing no
  content and no timestamp — so this set includes everything already
  regenerated since the change. It is an **upper bound** on what needs
  attention, and it is named for what it measures.

  `nw.stale_after` is the narrower answer and it now cuts off early
  (nw#24), so switching this field to it would return a smaller and
  correct set. That is deliberately **not** done here: the field would
  then be named for the wrong measurement, and which of the two a
  resumption brief should show is nw#7’s call, not nw#24’s. Callers who
  want the exact set can call `nw.stale_after` with
  `last_authored_change_id`.
- The walk starts at the last **authored** change — the most recent
  annotation the user wrote (a shot, a section, a character or
  environment ref), never one a Transform derived. Walking from “the
  newest annotation” instead would be inverted: the newest node in a
  provenance graph is by construction a *leaf*, so its descendant set is
  empty in exactly the case the field exists for.
- `total_spend_usd` sums *every* recorded render decision across
  every store scope. Nothing records per-branch outcomes yet, so a render
  that failed after being billed is counted here exactly like one that
  succeeded. Also an upper bound.

`caveats` carries those qualifications as data — so a consumer
renders them next to the numbers instead of rediscovering them.

#### model_config *: [ClassVar](https://docs.python.org/3/library/typing.html#typing.ClassVar)[ConfigDict]* *= {'extra': 'ignore'}*

Configuration for the model, should be a dictionary conforming to [`ConfigDict`][pydantic.config.ConfigDict].

### *class* nw.schema.SectionSpec(\*\*data)

Bases: `BaseModel`

A non-overlapping span of the project’s master timeline.

`label` is free-form (“intro”, “verse”, “chorus”, “scene-1”, “act-2”,
…) so different apps (music-video, explainer, podcast-clip) can use
their own taxonomy.

#### model_config *: [ClassVar](https://docs.python.org/3/library/typing.html#typing.ClassVar)[ConfigDict]* *= {'extra': 'ignore'}*

Configuration for the model, should be a dictionary conforming to [`ConfigDict`][pydantic.config.ConfigDict].

### *class* nw.schema.ShotSpec(\*\*data)

Bases: `BaseModel`

A timeline-locked visual unit.

`[start_s, end_s)` is half-open. `render_strategy` is an open string
rather than a closed Literal, so apps can register their own strategies
via [`nw.renderers.register_strategy()`](_autosummary/nw.renderers.html.md#nw.renderers.register_strategy) (Phase 1b.3) without modifying
the schema.

#### model_config *: [ClassVar](https://docs.python.org/3/library/typing.html#typing.ClassVar)[ConfigDict]* *= {'extra': 'ignore'}*

Configuration for the model, should be a dictionary conforming to [`ConfigDict`][pydantic.config.ConfigDict].

### *class* nw.schema.SongInfo(\*\*data)

Bases: `BaseModel`

Metadata for the master audio file.

Compatible with muvid’s SongInfo by field name and type.

#### model_config *: [ClassVar](https://docs.python.org/3/library/typing.html#typing.ClassVar)[ConfigDict]* *= {'extra': 'ignore'}*

Configuration for the model, should be a dictionary conforming to [`ConfigDict`][pydantic.config.ConfigDict].


# _autosummary/nw.script_segmentation.html.md

# nw.script_segmentation

`nw.script_segmentation` — narrow LLM-backed helper that converts a
free-form script into a list of storyboard-panel proposals.

This module is intentionally **focused and narrow**: it’s the smallest
possible thing that turns “user pasted some prose” into “n panels with
descriptions and durations” so a downstream UI can render them. It is
*not* a full [`nw.transforms.Transform`](_autosummary/nw.html.md#nw.Transform) — that abstraction will
absorb this work once it lands. For now we keep the surface as a plain
function with a dependency-injection seam (the `llm` arg), so:

- Tests can pass a deterministic stub (or a cassette-wrapped function)
  without needing API keys or network.
- The real implementation can swap between OpenAI / Anthropic / local
  models without callers caring.
- The cost-honesty rule (every billable call should be inspectable) is
  trivially upheld: the seam is the call.

Persisting the proposals as annotations is the *caller’s* job (this
module is pure — no project I/O). See
`reelee_backend.handlers.post_script_segment` for the wiring.

### Module Attributes

| [`LLM`](_autosummary/nw.script_segmentation.html.md#nw.script_segmentation.LLM)   | The LLM seam — any function taking a string prompt and returning a string response.   |
|--------------------------------------------------------|---------------------------------------------------------------------------------------|

### Functions

| [`build_prompt`](_autosummary/nw.script_segmentation.html.md#nw.script_segmentation.build_prompt)(script, \*, target_panel_count)   | The canonical prompt string sent to the LLM.                |
|-------------------------------------------------------------------------------------------------|-------------------------------------------------------------|
| [`segment_script_into_panels`](_autosummary/nw.script_segmentation.html.md#nw.script_segmentation.segment_script_into_panels)(script, \*, ...)    | Segment `script` into `target_panel_count` panel proposals. |

### Classes

| [`PanelProposal`](_autosummary/nw.script_segmentation.html.md#nw.script_segmentation.PanelProposal)(\*\*data)   | One storyboard panel proposed by the segmenter.   |
|----------------------------------------------------------------------------|---------------------------------------------------|

### nw.script_segmentation.LLM

The LLM seam — any function taking a string prompt and returning a
string response. Tests pass a cassette-wrapped stub; production passes
`oa.chat` (or whatever’s been wired).

alias of `Callable`[[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)], [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]

### *class* nw.script_segmentation.PanelProposal(\*\*data)

Bases: `BaseModel`

One storyboard panel proposed by the segmenter.

The shape is intentionally close to `annot://schema/storyboard-panel/v1`
(the lacing body schema) so the caller can promote a proposal into a
real panel annotation with a minimal mapping step.

#### model_config *: [ClassVar](https://docs.python.org/3/library/typing.html#typing.ClassVar)[ConfigDict]* *= {}*

Configuration for the model, should be a dictionary conforming to [`ConfigDict`][pydantic.config.ConfigDict].

### nw.script_segmentation.build_prompt(script, , target_panel_count)

The canonical prompt string sent to the LLM. Exposed so callers

+ tests can inspect / version it. **The cassette hashes this string**,
  so any change here invalidates recorded fixtures.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

### nw.script_segmentation.segment_script_into_panels(script, , target_panel_count, llm)

Segment `script` into `target_panel_count` panel proposals.

Pure function — no I/O beyond the `llm` callable. The caller is
responsible for choosing / wrapping the LLM (e.g. with a cassette
or with caching).

* **Parameters:**
  * **script** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str)) – Free-form prose. Whitespace is preserved verbatim in
    the prompt, so trimming + canonicalisation is the caller’s
    decision.
  * **target_panel_count** ([`int`](https://docs.python.org/3/builtins/functions.html#int)) – Soft target — the LLM is asked for exactly
    this many. Real-world deviations of ±1 are tolerated.
  * **llm** ([`Callable`](https://docs.python.org/3/library/typing.html#typing.Callable)[[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)], [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]) – The text→text seam. Receives the formatted prompt, must
    return a string. The expected response is a JSON array of
    `{description, duration_s, notes}` objects.
* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`PanelProposal`](_autosummary/nw.script_segmentation.html.md#nw.script_segmentation.PanelProposal)]
* **Returns:**
  A list of validated [`PanelProposal`](_autosummary/nw.script_segmentation.html.md#nw.script_segmentation.PanelProposal) instances.
* **Raises:**
  [**ValueError**](https://docs.python.org/3/builtins/exceptions.html#ValueError) – The LLM response could not be parsed as a JSON
      array of panels, or no valid panels survived validation.


# _autosummary/nw.secrets.html.md

# nw.secrets

Execution secrets — credentials that reach `execute` and nothing else.

A caller’s bring-your-own API key has to reach the one place that spends it
([`nw.Transform.execute()`](_autosummary/nw.html.md#nw.Transform.execute), or the render callable behind a
[`nw.jobs.enqueue()`](_autosummary/nw.jobs.html.md#nw.jobs.enqueue)) **without** going through the graph: a key must
never be persisted in a node body, provenance, a `falaw.Plan`, a cache
key, a run record, the job index or a log line. This module is the seam that
carries it, and [`Secrets`](_autosummary/nw.secrets.html.md#nw.secrets.Secrets) is what makes the invariant enforced rather
than promised.

**The shape.** `execute(..., *, secrets=...)` is a keyword-only argument,
passed accepts-it-or-not by [`nw.fan_out_execute()`](_autosummary/nw.html.md#nw.fan_out_execute) and by
[`nw.jobs.enqueue()`](_autosummary/nw.jobs.html.md#nw.jobs.enqueue)’s dispatch — the same seam `on_failure` (nw#25) and
`unit_instance_id` (nw#44) use — so a Transform that spends a caller’s
credential declares the keyword, and one that does not never sees it.
Secrets are keyed by **provider name** (`"fal"`, `"elevenlabs"`, …): nw
owns [`FAL_SECRET`](_autosummary/nw.secrets.html.md#nw.secrets.FAL_SECRET), an app owns the names of the providers it calls.

**Why a type, not a dict.** A [`Secrets`](_autosummary/nw.secrets.html.md#nw.secrets.Secrets) is a read-only
[`Mapping`](https://docs.python.org/3/library/collections.abc.html#collections.abc.Mapping) whose `repr`/`str` redact every value,
that refuses to be pickled, and that is deliberately *not* a `dict` — so
`json.dumps` (and pydantic) of anything that accidentally holds one raises
instead of writing the key. Absent values are dropped at construction, so a
boundary can pass an optional header value straight through:
`Secrets(elevenlabs=request_header)` is empty — and falsy — when the header
was not sent, which every consumer reads as “use the process environment”.

```pycon
>>> s = Secrets(elevenlabs="sk-live-…", fal=None)
>>> sorted(s)
['elevenlabs']
>>> s
Secrets(<1 redacted: elevenlabs>)
>>> bool(Secrets(fal=None))
False
>>> import json
>>> json.dumps({"secrets": s})  # a record can never carry one by accident
Traceback (most recent call last):
    ...
TypeError: Object of type Secrets is not JSON serializable
```

### Module Attributes

| [`FAL_SECRET`](_autosummary/nw.secrets.html.md#nw.secrets.FAL_SECRET)   | `nw.BaseTransform.execute()` and the [`nw.jobs`](_autosummary/nw.jobs.html.md#module-nw.jobs) worker bind it as the fal credential (`falaw.using_fal_credentials()`) for the duration of the call.   |
|---------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|

### Functions

| [`as_secrets`](_autosummary/nw.secrets.html.md#nw.secrets.as_secrets)(secrets)              | Coerce a caller-supplied mapping to [`Secrets`](_autosummary/nw.secrets.html.md#nw.secrets.Secrets); empty → `None`.   |
|-----------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------|
| [`using_secrets`](_autosummary/nw.secrets.html.md#nw.secrets.using_secrets)(secrets)           | Bind the secrets nw itself knows how to use, for the duration of a block.                                       |
| [`redact`](_autosummary/nw.secrets.html.md#nw.secrets.redact)(text, secrets)            | `text` with every secret value replaced by `<redacted:name>`.                                                   |
| [`redact_exception`](_autosummary/nw.secrets.html.md#nw.secrets.redact_exception)(error, secrets) | The exception to re-raise so that nothing it *renders* carries a secret.                                        |

### Classes

| [`Secrets`](_autosummary/nw.secrets.html.md#nw.secrets.Secrets)([mapping])   | A read-only `{provider_name: key}` mapping that never prints or persists.   |
|-----------------------------------------------------------------------|-----------------------------------------------------------------------------|

### Exceptions

| [`RedactedError`](_autosummary/nw.secrets.html.md#nw.secrets.RedactedError)(message, \*, original_type)   | An exception re-raised in place of one whose rendered text quoted a secret and whose type could not be rebuilt with the scrubbed text.   |
|----------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------|

### nw.secrets.FAL_SECRET *= 'fal'*

`nw.BaseTransform.execute()` and the
[`nw.jobs`](_autosummary/nw.jobs.html.md#module-nw.jobs) worker bind it as the fal credential
(`falaw.using_fal_credentials()`) for the duration of the call.

* **Type:**
  The secret name nw itself consumes

### *exception* nw.secrets.RedactedError(message, , original_type)

Bases: [`RuntimeError`](https://docs.python.org/3/builtins/exceptions.html#RuntimeError)

An exception re-raised in place of one whose rendered text quoted a secret
and whose type could not be rebuilt with the scrubbed text.

`original_type` names what it stood in for, so a caller classifying on
the falaw hierarchy still learns what happened; `str()` is the scrubbed
rendering. The typed fallback of [`redact_exception()`](_autosummary/nw.secrets.html.md#nw.secrets.redact_exception).

### *class* nw.secrets.Secrets(mapping=None, , \*\*named)

Bases: [`Mapping`](https://docs.python.org/3/library/collections.abc.html#collections.abc.Mapping)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]

A read-only `{provider_name: key}` mapping that never prints or persists.

Construct from a mapping, keywords, or both; `None`/empty values are
dropped (absent means “not supplied”), a non-`str` key or value is a
`TypeError` — a secret is text, and an int or a bytes object here is a
caller bug worth failing on.

#### with_(\*\*named)

A copy with `named` layered on top (a boundary adding a provider).

* **Return type:**
  [`Secrets`](_autosummary/nw.secrets.html.md#nw.secrets.Secrets)

### nw.secrets.as_secrets(secrets)

Coerce a caller-supplied mapping to [`Secrets`](_autosummary/nw.secrets.html.md#nw.secrets.Secrets); empty → `None`.

The nw entry points — `nw.BaseTransform.execute()`,
[`nw.fan_out_execute()`](_autosummary/nw.html.md#nw.fan_out_execute), [`nw.jobs.enqueue()`](_autosummary/nw.jobs.html.md#nw.jobs.enqueue) — run every incoming
`secrets` through this, so below *them* a Transform only ever sees the
redacting type. A Transform that **overrides** `execute` and is called
directly gets whatever the caller passed: an override that logs or
formats its `secrets` should `as_secrets` first (or the caller should
hand it a [`Secrets`](_autosummary/nw.secrets.html.md#nw.secrets.Secrets)), because a plain `dict` prints its values.

* **Return type:**
  [`Optional`](https://docs.python.org/3/library/typing.html#typing.Optional)[[`Secrets`](_autosummary/nw.secrets.html.md#nw.secrets.Secrets)]

```pycon
>>> as_secrets(None) is None
True
>>> as_secrets({"fal": None}) is None
True
>>> as_secrets({"fal": "k"})
Secrets(<1 redacted: fal>)
```

### nw.secrets.redact(text, secrets)

`text` with every secret value replaced by `<redacted:name>`.

For the places nw persists free text it did not author — an exception
message, a failure reason — while holding the values that must not land
there. Cheap, exact-substring, and a no-op with no secrets.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

```pycon
>>> redact("boom: key sk-1 rejected", {"fal": "sk-1"})
'boom: key <redacted:fal> rejected'
>>> redact("nothing here", None)
'nothing here'
```

### nw.secrets.redact_exception(error, secrets)

The exception to re-raise so that nothing it *renders* carries a secret.

Scrubs `args` and `__notes__` in place and, when `str(error)` is
still not clean — an exception whose message is built from a non-string
arg (`RuntimeError({"detail": key})`, `OSError(2, msg, path)`) or a
custom `__str__` — rebuilds it as `type(error)(scrubbed_text)`, falling
back to [`RedactedError`](_autosummary/nw.secrets.html.md#nw.secrets.RedactedError) when the type will not construct that way
or still renders the secret. The cause/context chain is scrubbed the same
way. Returns the object to raise: the original when it was already clean.

Applied where nw lets an exception escape toward a store it does not own
(the job worker: au persists the rendered text) or files it into a record
it does (a fan-out unit’s `reason`).

* **Return type:**
  [`BaseException`](https://docs.python.org/3/builtins/exceptions.html#BaseException)

### nw.secrets.using_secrets(secrets)

Bind the secrets nw itself knows how to use, for the duration of a block.

Today that is [`FAL_SECRET`](_autosummary/nw.secrets.html.md#nw.secrets.FAL_SECRET): when present it becomes the fal
credential (`falaw.using_fal_credentials()`) so every `call_fal`
inside the block authenticates with the caller’s key instead of the
server’s `FAL_KEY`. Anything else in `secrets` is left for the
Transform that declared it. With no fal secret this is a `nullcontext`,
so the `with` shape stays uniform.

* **Return type:**
  [`AbstractContextManager`](https://docs.python.org/3/library/contextlib.html#contextlib.AbstractContextManager)[[`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]


# _autosummary/nw.storyboard.html.md

# nw.storyboard

Storyboard ↔ Project bridge.

A storyboard lives at `<project_root>/storyboard.annot.sqlite` (a lacing
`SqliteStore`). Each panel is an `annot://schema/storyboard-panel/v1`
`lacing.Annotation`; the storyboard’s own asset_id is the project’s
song hash, so panels share an interval space with the project’s shots and
alignment.

Public surface:

- `open_storyboard(project)()` — load the project’s storyboard, or return
  an empty one if none exists yet.
- `save_storyboard(project, sb, *, panel_intervals)()` — persist panels
  into the project’s lacing store.
- `storyboard_from_shots(project)()` — convenience: build a Storyboard
  with one panel per shot, intervals matching the shots.
- `plan_render_panel_images(sb, *, quality, model_overrides)()` — a
  Plan with one `generate_image` call per panel that doesn’t yet have a
  `role="seed"` image. Pure data; cost-aware.
- `execute_render_panel_images(project, sb, plan)()` — execute the Plan,
  download images into `storyboard/` under the project, return an updated
  Storyboard with the new `role="seed"` PanelImages attached.

The artful package is the storyboard *data layer*; nw.storyboard wires it
into a folder-backed nw project.

### Functions

| [`execute_render_panel_images`](_autosummary/nw.storyboard.html.md#nw.storyboard.execute_render_panel_images)(project, ...[, ...])   | Execute `plan`, download each artifact, attach a PanelImage.            |
|-----------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------|
| [`open_storyboard`](_autosummary/nw.storyboard.html.md#nw.storyboard.open_storyboard)(project)                           | Load the project's storyboard.                                          |
| [`plan_render_panel_images`](_autosummary/nw.storyboard.html.md#nw.storyboard.plan_render_panel_images)(storyboard, \*[, ...])    | Build a Plan that generates a seed image for each panel that lacks one. |
| [`project_asset_id`](_autosummary/nw.storyboard.html.md#nw.storyboard.project_asset_id)(project)                          | The asset_id used for storyboard panel references.                      |
| [`save_storyboard`](_autosummary/nw.storyboard.html.md#nw.storyboard.save_storyboard)(project, storyboard, \*, ...)      | Persist a Storyboard into the project's SqliteStore.                    |
| [`storyboard_db_path`](_autosummary/nw.storyboard.html.md#nw.storyboard.storyboard_db_path)(project)                        | Return the path to the project's storyboard SQLite store.               |
| [`storyboard_from_shots`](_autosummary/nw.storyboard.html.md#nw.storyboard.storyboard_from_shots)(project, \*[, title, style]) | Build a one-panel-per-shot draft Storyboard from a project's shots.     |

### nw.storyboard.execute_render_panel_images(project, storyboard, plan, panel_ids, , on_event=None, use_cache=True, on_failure='halt')

Execute `plan`, download each artifact, attach a PanelImage.

Returns a NEW `Storyboard` (input `storyboard` is unchanged) with
the materialized seed images attached as `role="seed"` PanelImages.

Files land under `<project_root>/storyboard/<panel_id>.png`. The
PanelImage record stores both the project-relative path and the
artifact_id (content hash via lacing.Artifact), so downstream consumers
can prefer one or the other.

`on_failure` is nw#25’s policy, and this is the function the issue names
as **nw’s real fan-out shape** — one `generate_image` per panel. Under
`"isolate"` a panel whose call failed is simply left without a seed image;
every panel that rendered keeps its own, instead of one content-filtered
panel discarding the whole batch. `"halt"` is the default and unchanged.

Panels are matched to outcomes **by index into the plan**, never by position
in a shortened artifact list — the latter attaches panel 48’s image to panel
47 the moment one call drops out.

* **Return type:**
  `Storyboard`

### nw.storyboard.open_storyboard(project)

Load the project’s storyboard. Returns an empty one if not present.

* **Return type:**
  `Storyboard`

### nw.storyboard.plan_render_panel_images(storyboard, , quality='balanced', image_size='landscape_16_9', model_id=None, only_missing=True)

Build a Plan that generates a seed image for each panel that lacks one.

* **Parameters:**
  * **storyboard** (`Storyboard`) – The `artful.Storyboard`.
  * **quality** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str)) – image-gen quality tier.
  * **image_size** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str)) – “landscape_16_9” by default; respects the storyboard’s
    aspect when it can be mapped to a falaw size, otherwise uses
    this default.
  * **model_id** ([`Optional`](https://docs.python.org/3/library/typing.html#typing.Optional)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]) – Override the image-gen model. Defaults to whatever
    `falaw.pick_model(category="image", quality_tier=quality)`
    picks (e.g. flux/dev at balanced).
  * **only_missing** ([`bool`](https://docs.python.org/3/builtins/functions.html#bool)) – When True (default), skip panels that already have a
    `role="seed"` image. When False, plan one call per panel
    regardless.
* **Return type:**
  [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[`Plan`, [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]]
* **Returns:**
  `(plan, panel_ids)` — the Plan, and the panel ids in the same
  order as the Plan’s calls (so [`execute_render_panel_images()`](_autosummary/nw.storyboard.html.md#nw.storyboard.execute_render_panel_images)
  knows which panel each artifact belongs to).

### nw.storyboard.project_asset_id(project)

The asset_id used for storyboard panel references.

Uses the SHA-256 of the project’s song bytes when available, so the
asset_id matches whatever a downstream consumer would compute via
`lacing.hash_file()`. Falls back to a stable derived id when the
song isn’t available yet.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

### nw.storyboard.save_storyboard(project, storyboard, , panel_intervals, was_attributed_to='user:nw', was_generated_by='agent:nw.storyboard')

Persist a Storyboard into the project’s SqliteStore.

Wipes the existing storyboard panels (under the default tier) so the
save is idempotent — re-running with edited panels replaces them rather
than accumulating duplicates.

* **Return type:**
  [`None`](https://docs.python.org/3/builtins/constants.html#None)

### nw.storyboard.storyboard_db_path(project)

Return the path to the project’s storyboard SQLite store.

* **Return type:**
  [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)

### nw.storyboard.storyboard_from_shots(project, , title=None, style=None)

Build a one-panel-per-shot draft Storyboard from a project’s shots.

Each panel’s caption defaults to the shot’s description, framing and
camera carry over, and the panel’s `shot_id` points back at the shot.
No images are attached yet — use [`plan_render_panel_images()`](_autosummary/nw.storyboard.html.md#nw.storyboard.plan_render_panel_images) to
generate them.

Returns `(storyboard, panel_intervals)` so the caller can feed both
into [`save_storyboard()`](_autosummary/nw.storyboard.html.md#nw.storyboard.save_storyboard).

* **Return type:**
  [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[`Storyboard`, [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), `TimeInterval`]]


# _autosummary/nw.transforms.html.md

# nw.transforms

### nw.transforms *= <Registry nw.transforms>*

A typed dict-backed plugin registry.

* **Parameters:**
  * **name** – Optional name; appears in error messages and `repr`.
  * **on_conflict** – `'error'` (default) raises `RegistryConflict` when registering
    a key that already exists. `'replace'` silently overwrites.
    `'keep'` silently keeps the original.
  * **(****dict****(****...****)** (*Implements MutableMapping so anything that takes a mapping*)

:param :
:param iteration:
:param length checks:
:param `in` lookups:
:param `.items()`) just works.:


# _autosummary/nw.validation.html.md

# nw.validation

Pluggable validation of finished work — the seam, not the checks.

**What this is for.** A render that finishes without raising is not a render
that is *right*. A one-minute short shipped with a burnt-in lower third reading
`1981 · Centr…`; a ten-minute cut shipped two-thirds encoded with a container
duration taken from its audio stream, so every duration check passed; a Ken
Burns move jumped a whole pixel mid-panel for months. Each was found by a human
watching the finished file, which is the most expensive detector we have and the
only one that gets tired.

**What this module owns, and what it deliberately does not.** It owns the
*seam*: how a check is declared, how the menu of available checks is assembled,
how dependencies between them are resolved, in what order and with how much
concurrency they run, and what a run of them reports. It owns no domain
knowledge whatsoever — every actual check is a plugin, registered from wherever
the knowledge lives (nw ships the two that [`nw.inspect`](_autosummary/nw.inspect.html.md#module-nw.inspect) already knew how to
do; a check about on-screen type belongs next to `tituli`, one about camera
motion next to `burns`).

**Where it goes in a pipeline.** Nowhere, by default. Validation is a thing you
*place*, and where to place it is a judgement about cost and consequence:

* before showing a result to a human, if the checks are cheap;
* before an irreversible or outward-facing step — a publish, an upload, a send —
  where the checks earn their cost whatever they cost;
* both, with a cheaper selection at the first point than the second.

So the surface is one function, [`validate()`](_autosummary/nw.validation.html.md#nw.validation.validate), and callers place it. Nothing
in nw calls it for you; a gate you did not ask for is a gate that fires at the
wrong moment.

**Declaring a check**:

```default
from nw.validation import Check, checks

@checks.register_decorator("video.has_both_streams")
def _has_both_streams():
    return Check(
        name="video.has_both_streams",
        summary="the file actually contains a video and an audio stream",
        run=lambda target, ctx: (...),
        example_requests=("is the video ok", "did the render work"),
    )
```

`example_requests` is not decoration. A menu of forty checks is unusable by a
human and unselectable by a model; the phrases a person actually says are what
lets [`suggest()`](_autosummary/nw.validation.html.md#nw.validation.suggest) turn “make sure the captions look right” into a selection,
which is how this reaches an MCP tool surface without a forty-item enum.

### Examples

```pycon
>>> report = validate("some.mp4", checks=())   # nothing selected, nothing run
>>> report.ok, list(report.findings)
(True, [])
```

### Module Attributes

| [`checks`](_autosummary/nw.validation.html.md#nw.validation.checks)   | The menu.   |
|-----------------------------------------------------------|-------------|

### Functions

| [`register_check`](_autosummary/nw.validation.html.md#nw.validation.register_check)([check])                          | Add a check to the menu, as a call or as a decorator.                   |
|---------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------|
| [`resolve_checks`](_autosummary/nw.validation.html.md#nw.validation.resolve_checks)(selection)                        | Selection plus everything it requires, with names resolved to Checks.   |
| [`plan_checks`](_autosummary/nw.validation.html.md#nw.validation.plan_checks)(selection)                           | Order the selection into waves that may each run concurrently.          |
| [`validate`](_autosummary/nw.validation.html.md#nw.validation.validate)(target, \*[, checks, max_workers, ...]) | Run `checks` against `target` and report.                               |
| [`suggest`](_autosummary/nw.validation.html.md#nw.validation.suggest)(request, \*[, include_paid])             | Checks whose `example_requests` look like what the user just asked for. |
| [`menu`](_autosummary/nw.validation.html.md#nw.validation.menu)(\*[, cost])                                 | Every registered check, name-ordered — what a user chooses from.        |

### Classes

| [`Finding`](_autosummary/nw.validation.html.md#nw.validation.Finding)(check, severity, message[, where, ...])   | One thing a check noticed.                                                             |
|----------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------|
| [`CheckResult`](_autosummary/nw.validation.html.md#nw.validation.CheckResult)(name[, findings, skipped, ...])       | What one check produced, including the case where it could not run.                    |
| [`ValidationReport`](_autosummary/nw.validation.html.md#nw.validation.ValidationReport)(target[, results, elapsed_s])    | Everything a [`validate()`](_autosummary/nw.validation.html.md#nw.validation.validate) run produced. |
| [`Check`](_autosummary/nw.validation.html.md#nw.validation.Check)(name, summary, run[, requires, ...])        | One validation, and everything a scheduler and a menu need to know.                    |

### Exceptions

| [`ValidationError`](_autosummary/nw.validation.html.md#nw.validation.ValidationError)(report)   | Raised by [`ValidationReport.raise_if_failed()`](_autosummary/nw.validation.html.md#nw.validation.ValidationReport.raise_if_failed).   |
|----------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------|

### *class* nw.validation.Check(name, summary, run, requires=(), parallel_safe=True, cost='cheap', example_requests=(), requires_binaries=())

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

One validation, and everything a scheduler and a menu need to know.

#### name

dotted and stable — it is what a user selects and what a
`requires` refers to.

#### summary

one line, for the menu.

#### run

`(target, context) -> findings`. May also return a
`(findings, produced)` pair when other checks depend on it.

#### requires

names of checks that must run first, whose `produced`
values arrive in `context`. Cycles raise at plan time.

#### parallel_safe

whether it may run alongside its independent peers.
`False` for anything that is not thread-safe or that saturates
the machine on its own (a full decode).

#### cost

rough wall-clock class — `"free"` (no subprocess),
`"cheap"` (seconds), `"dear"` (a full pass over the media), or
`"paid"` (spends money, e.g. a hosted OCR or transcription).
`"paid"` is never selected by [`suggest()`](_autosummary/nw.validation.html.md#nw.validation.suggest); it must be asked
for by name.

#### example_requests

things a person actually says that mean they want
this check. What lets a menu of forty be navigated, and what an
MCP surface matches against instead of exposing an enum.

#### requires_binaries

external programs it shells out to. A missing one
makes the check *skip with a reason*, never silently pass.

### *class* nw.validation.CheckResult(name, findings=(), skipped='', error='', elapsed_s=0.0, produced=None)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

What one check produced, including the case where it could not run.

`skipped` and `error` are kept distinct from “found nothing”, because
conflating them is how a validation suite comes to report all-clear on a
machine where half of it never ran. A missing binary is not a pass.

#### *property* ok *: [bool](https://docs.python.org/3/builtins/functions.html#bool)*

Ran, and found nothing at or above `FAILING_SEVERITY`.

### *class* nw.validation.Finding(check, severity, message, where='', remedy=None, evidence=<factory>)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

One thing a check noticed.

A finding is never a bare boolean. Whoever reads it — a human deciding
whether to publish, or a model deciding what to fix — needs to know *where*
in the work it is and *what* would make it go away, and a check that cannot
say those two things has not finished its job.

#### check

the name of the check that produced it.

#### severity

`"info"`, `"warn"` or `"error"`; only `"error"`
makes a report not `ok`.

#### message

what is wrong, in one sentence a human can act on.

#### where

where in the work — a timestamp, a frame index, a shot id, a
path. Free-form because the checks are, but never empty for
anything above `"info"`.

#### remedy

what would fix it, when the check knows. `None` when it
honestly does not.

#### evidence

anything a reader would want to look at — an extracted
frame’s path, the numbers behind the verdict.

### *exception* nw.validation.ValidationError(report)

Bases: [`AssertionError`](https://docs.python.org/3/builtins/exceptions.html#AssertionError)

Raised by [`ValidationReport.raise_if_failed()`](_autosummary/nw.validation.html.md#nw.validation.ValidationReport.raise_if_failed).

### *class* nw.validation.ValidationReport(target, results=(), elapsed_s=0.0)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

Everything a [`validate()`](_autosummary/nw.validation.html.md#nw.validation.validate) run produced.

#### target

what was validated.

#### results

one per check that was selected, in the order they were run.

#### elapsed_s

wall-clock for the whole run.

#### *property* ok *: [bool](https://docs.python.org/3/builtins/functions.html#bool)*

No failing findings **and** nothing that failed to run.

A check that errored is not a pass. Callers gating a publish on this
get the conservative answer without having to remember to ask for it.

#### raise_if_failed()

Return self, or raise [`ValidationError`](_autosummary/nw.validation.html.md#nw.validation.ValidationError) — for a hard gate.

* **Return type:**
  [`ValidationReport`](_autosummary/nw.validation.html.md#nw.validation.ValidationReport)

#### summary()

A few lines a human can read without unpacking the object.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

### nw.validation.checks *: Registry* *= <Registry nw.validation.checks>*

The menu. `on_conflict="error"` so a plugin that shadows a built-in fails
loudly rather than quietly changing what “validated” means.

### nw.validation.menu(, cost=None)

Every registered check, name-ordered — what a user chooses from.

* **Return type:**
  [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`Check`](_autosummary/nw.validation.html.md#nw.validation.Check), [`...`](https://docs.python.org/3/builtins/constants.html#Ellipsis)]

### nw.validation.plan_checks(selection)

Order the selection into waves that may each run concurrently.

Every check in a wave has all its requirements satisfied by earlier waves,
so the waves are the schedule: run each in turn, in parallel within it.
A check that is not `parallel_safe` gets a wave to itself.

* **Raises:**
  [**ValueError**](https://docs.python.org/3/builtins/exceptions.html#ValueError) – on a dependency cycle, or a requirement that is not
      registered — both at plan time, before anything has been spent.
* **Return type:**
  [*tuple*](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[*tuple*](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[*Check*](_autosummary/nw.validation.html.md#nw.validation.Check), …], …]

### Examples

```pycon
>>> plan_checks(())
()
```

* **Return type:**
  [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`Check`](_autosummary/nw.validation.html.md#nw.validation.Check), [`...`](https://docs.python.org/3/builtins/constants.html#Ellipsis)], [`...`](https://docs.python.org/3/builtins/constants.html#Ellipsis)]

### nw.validation.register_check(check=None, \*\*kwargs)

Add a check to the menu, as a call or as a decorator.

As a call:

```default
register_check(Check(name="video.duration", summary="...", run=...))
```

As a decorator on the run function, with the rest as keywords:

```default
@register_check(name="video.duration", summary="...", cost="cheap")
def _duration(target, ctx): ...
```

### nw.validation.resolve_checks(selection)

Selection plus everything it requires, with names resolved to Checks.

A user picks what they care about; what those checks *need* is not their
problem. Raises `KeyError` for an unknown name — a silently dropped check
is the failure this whole module exists to prevent.

* **Return type:**
  [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`Check`](_autosummary/nw.validation.html.md#nw.validation.Check), [`...`](https://docs.python.org/3/builtins/constants.html#Ellipsis)]

### nw.validation.suggest(request, , include_paid=False)

Checks whose `example_requests` look like what the user just asked for.

Deliberately crude — a word-overlap score, not a model call — because this
runs on every request and its job is to narrow forty items to a handful
that a human or a model then confirms. `"paid"` checks are never
suggested: money is asked for by name.

* **Return type:**
  [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`Check`](_autosummary/nw.validation.html.md#nw.validation.Check), [`...`](https://docs.python.org/3/builtins/constants.html#Ellipsis)]

### Examples

```pycon
>>> suggest("")
()
```

### nw.validation.validate(target, , checks=(), max_workers=4, on_error='report')

Run `checks` against `target` and report.

* **Parameters:**
  * **target** ([`Any`](https://docs.python.org/3/library/typing.html#typing.Any)) – whatever the checks understand — a path to a rendered file, a
    `Project`, a `(video, annotations)` pair. This module does not
    care; it is the checks that agree with their caller.
  * **checks** ([`Iterable`](https://docs.python.org/3/library/typing.html#typing.Iterable)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str) | [`Check`](_autosummary/nw.validation.html.md#nw.validation.Check)]) – names or [`Check`](_autosummary/nw.validation.html.md#nw.validation.Check) objects. Requirements are pulled in
    automatically. Empty means empty: validation is placed, never
    assumed.
  * **max_workers** ([`int`](https://docs.python.org/3/builtins/functions.html#int)) – concurrency within a wave.
  * **on_error** ([`Literal`](https://docs.python.org/3/library/typing.html#typing.Literal)[`'report'`, `'raise'`]) – `"report"` records a raising check as an errored
    [`CheckResult`](_autosummary/nw.validation.html.md#nw.validation.CheckResult) and carries on, so one broken plugin cannot
    hide the findings of the other nine. `"raise"` is for developing
    a check.
* **Return type:**
  [`ValidationReport`](_autosummary/nw.validation.html.md#nw.validation.ValidationReport)
* **Returns:**
  A [`ValidationReport`](_autosummary/nw.validation.html.md#nw.validation.ValidationReport). Note that `report.ok` is `False` when
  a check *errored*, not only when one failed: a suite that could not run
  has not said the work is good.

### Examples

```pycon
>>> validate("x.mp4").ok
True
```


# _autosummary/nw.workflow.html.md

# nw.workflow

Workflow: prepare → plan → execute, for a **video shot**.

**Scope — read this before extending anything here.** This module and
[`nw.renderers`](_autosummary/nw.renderers.html.md#module-nw.renderers) are the *shot* render unit: they bake in
[`nw.schema.ShotSpec`](_autosummary/nw.schema.html.md#nw.schema.ShotSpec), an open-string `render_strategy`, and an
`output.mp4`. They are **not** the render-kind-agnostic engine, and they are
**not** the place to add a new render *kind*. That engine is
[`nw.transforms`](_autosummary/nw.transforms.html.md#nw.transforms), whose `plan`/`execute` split is the same shape over
arbitrary annotation kinds — it is what reelee’s production video path and
braidio’s audio-weave path both ride.

\*\*This is a layer *under* that engine, not a path parallel to it — and it is
not dead code.\*\* The engine’s shot arrow is built on top of this module:
`nw/transforms/_adapters/render_strategy.py`, which publishes every
`shot_to_render_result.fal.<strategy>` Transform, calls [`prepare_shot()`](_autosummary/nw.workflow.html.md#nw.workflow.prepare_shot)
in both its `plan` (`upload=params.upload`) and its `execute`
(`upload=False`); and [`plan_render_shot()`](_autosummary/nw.workflow.html.md#nw.workflow.plan_render_shot) / [`execute_render()`](_autosummary/nw.workflow.html.md#nw.workflow.execute_render) both
call [`nw.renderers.get_strategy()`](_autosummary/nw.renderers.html.md#nw.renderers.get_strategy). So a “cleanup” here — dropping
[`prepare_shot()`](_autosummary/nw.workflow.html.md#nw.workflow.prepare_shot)’s `upload=` keyword, deleting this module as unused —
silently breaks the Transform path that the docs hold up as the correct one.

What is true, and narrower than “legacy” sounds, is a statement about \*entry
points\*: measured 2026-08-27, [`prepare_shot()`](_autosummary/nw.workflow.html.md#nw.workflow.prepare_shot), [`plan_render_shot()`](_autosummary/nw.workflow.html.md#nw.workflow.plan_render_shot),
[`execute_render()`](_autosummary/nw.workflow.html.md#nw.workflow.execute_render) and [`nw.renderers.get_strategy()`](_autosummary/nw.renderers.html.md#nw.renderers.get_strategy) have **zero** call
sites outside nw (muvid has its own `muvid.schema.ShotSpec` and its own
ffmpeg strategies). Nothing downstream drives these functions *directly* today,
and new callers should go through the Transform registry instead — but inside
nw they are load-bearing. See `misc/docs/Rendering Provenance and Partial
Re-render.md` and nw#9.

The render pipeline has three phases, each cleanly separable:

1. **Prepare** ([`prepare_shot()`](_autosummary/nw.workflow.html.md#nw.workflow.prepare_shot)) — *local* work: extract the audio slice
   for the shot, find character/environment anchor images, gather lyric lines,
   build the storyboard prompt. No fal calls. Output: [`ShotPreparation`](_autosummary/nw.workflow.html.md#nw.workflow.ShotPreparation),
   a typed bundle of local file paths and prose.
2. **Plan** ([`plan_render_shot()`](_autosummary/nw.workflow.html.md#nw.workflow.plan_render_shot)) — *pure-data* work: given a
   [`ShotPreparation`](_autosummary/nw.workflow.html.md#nw.workflow.ShotPreparation), build a `falaw.Plan` of the fal calls
   > that will produce the shot. Returns the Plan + the list of artifacts
   > that haven’t been generated yet (e.g. uploads needed first). Still no
   > fal calls.
3. **Execute** ([`execute_render()`](_autosummary/nw.workflow.html.md#nw.workflow.execute_render)) — the *only* phase with fal contact.
   Uploads local files, drives the Plan, downloads outputs, trims/pads to
   the shot’s exact duration. Returns the final mp4 path.

This split is what enables:

- A budget gate that’s honest (cost is computed at plan time).
- Tests that exercise plan construction without a fal account.
- A UI that says “you’re about to spend $4.12, click confirm” before the
  network goes near a credit card.
- The “render then kill once audio.wav exists” hack from interface_design_plan
  (item #6) becomes one call: `prepare_shot(project, shot_id)`.

### Functions

| [`execute_render`](_autosummary/nw.workflow.html.md#nw.workflow.execute_render)(prep, plan, \*[, on_event, ...])   | Execute a Plan, materialize the result as `shot_dir/output.mp4`.   |
|----------------------------------------------------------------------------------------------------|--------------------------------------------------------------------|
| [`plan_render_shot`](_autosummary/nw.workflow.html.md#nw.workflow.plan_render_shot)(prep, \*[, quality, ...])        | Build a `falaw.Plan` for rendering a prepared shot.                |
| [`prepare_shot`](_autosummary/nw.workflow.html.md#nw.workflow.prepare_shot)(project, shot_id, \*[, upload])      | Resolve all local inputs for rendering a shot.                     |

### Classes

| [`ShotPreparation`](_autosummary/nw.workflow.html.md#nw.workflow.ShotPreparation)(project_root, shot, ...[, ...])   | Local-only inputs for rendering a single shot.   |
|----------------------------------------------------------------------------------------------------|--------------------------------------------------|

### *class* nw.workflow.ShotPreparation(project_root, shot, shot_dir, audio_slice_path, audio_slice_url='', character_anchor_paths=<factory>, character_anchor_urls=<factory>, environment_anchor_path=None, environment_anchor_url='', lyric_lines=<factory>, storyboard_prompt='', global_style='')

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

Local-only inputs for rendering a single shot.

Building a ShotPreparation is a pure-filesystem operation: no fal calls
that bill, no network beyond fal-storage uploads (which are free). The
upload step happens here so the resulting URLs are stable and the cache
key derived from them is honest.

Multiple downstream consumers (the planner, an inspection report, a UI
preview) can read this without re-doing the audio extraction.

#### audio_slice_path *: [Path](https://docs.python.org/3/library/pathlib.html#pathlib.Path)*

Local path to the song’s audio over [shot.start_s, shot.end_s].

#### audio_slice_url *: [str](https://docs.python.org/3/builtins/stdtypes.html#str)*

fal-storage URL of the audio slice (set by [`prepare_shot()`](_autosummary/nw.workflow.html.md#nw.workflow.prepare_shot) when
a fal API key is available; empty otherwise — strategies that need URLs
will raise descriptively).

#### character_anchor_paths *: [dict](https://docs.python.org/3/builtins/stdtypes.html#dict)[[str](https://docs.python.org/3/builtins/stdtypes.html#str), [Path](https://docs.python.org/3/library/pathlib.html#pathlib.Path)]*

Per-character path to the curated anchor image.

#### character_anchor_urls *: [dict](https://docs.python.org/3/builtins/stdtypes.html#dict)[[str](https://docs.python.org/3/builtins/stdtypes.html#str), [str](https://docs.python.org/3/builtins/stdtypes.html#str)]*

Per-character fal-storage URL of the anchor image.

#### environment_anchor_path *: [Path](https://docs.python.org/3/library/pathlib.html#pathlib.Path) | [None](https://docs.python.org/3/builtins/constants.html#None)*

Path to the environment establishing image, or None.

#### environment_anchor_url *: [str](https://docs.python.org/3/builtins/stdtypes.html#str)*

fal-storage URL of the environment image; empty if no env image.

#### lyric_lines *: [list](https://docs.python.org/3/builtins/stdtypes.html#list)[[dict](https://docs.python.org/3/builtins/stdtypes.html#dict)]*

List of `{"text", "start_s", "end_s", "line_index", "section"}` dicts.

#### storyboard_prompt *: [str](https://docs.python.org/3/builtins/stdtypes.html#str)*

shot description + framing + camera + characters +
environment + style + lyric lines (when present).

* **Type:**
  Full prose prompt

### nw.workflow.execute_render(prep, plan, , on_event=None, use_cache=True, project=None)

Execute a Plan, materialize the result as `shot_dir/output.mp4`.

Refuses to execute a plan-only Plan (one whose arguments still contain
`<plan-only:...>` placeholders) — those exist so the planner can show
cost without any uploads, and need to be replaced with real URLs (call
[`prepare_shot()`](_autosummary/nw.workflow.html.md#nw.workflow.prepare_shot) with `upload=True`) before execute.

* **Parameters:**
  * **prep** ([`ShotPreparation`](_autosummary/nw.workflow.html.md#nw.workflow.ShotPreparation)) – The [`ShotPreparation`](_autosummary/nw.workflow.html.md#nw.workflow.ShotPreparation) the Plan was built for.
  * **plan** (`Plan`) – A `falaw.Plan` (typically from [`plan_render_shot()`](_autosummary/nw.workflow.html.md#nw.workflow.plan_render_shot)).
  * **on_event** – Optional event subscriber forwarded to the falaw call layer.
  * **use_cache** ([`bool`](https://docs.python.org/3/builtins/functions.html#bool)) – When True (default), routes via `cached_call_fal` so
    cache hits skip the network.
  * **project** ([`Optional`](https://docs.python.org/3/library/typing.html#typing.Optional)[[`Project`](_autosummary/nw.project.html.md#nw.project.Project)]) – Optional `Project`. When given, a render-decision
    annotation is appended to the project graph after execution
    with `was_derived_from = (shot_annotation_id,)`, so reelee’s
    freshness queries (`descendants_of` / `stale_after`) walk
    from the shot to its render output.
* **Return type:**
  [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)
* **Returns:**
  Path to `shot_dir/output.mp4` (trimmed/padded to `prep.duration_s`).

### nw.workflow.plan_render_shot(prep, , quality='balanced', model_overrides=None)

Build a `falaw.Plan` for rendering a prepared shot.

Dispatches on `prep.shot.render_strategy` via [`nw.renderers`](_autosummary/nw.renderers.html.md#module-nw.renderers).

* **Parameters:**
  * **prep** ([`ShotPreparation`](_autosummary/nw.workflow.html.md#nw.workflow.ShotPreparation)) – A [`ShotPreparation`](_autosummary/nw.workflow.html.md#nw.workflow.ShotPreparation) from [`prepare_shot()`](_autosummary/nw.workflow.html.md#nw.workflow.prepare_shot).
  * **quality** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str)) – Default quality tier passed to the strategy.
  * **model_overrides** ([`Optional`](https://docs.python.org/3/library/typing.html#typing.Optional)[[`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]]) – Optional mapping of strategy-step → model_id, e.g.
    `{"avatar": "fal-ai/bytedance/omnihuman/v1.5"}` to bypass the
    default avatar model. The keys understood by each strategy are
    documented on the strategy itself.
* **Return type:**
  `Plan`
* **Returns:**
  A `falaw.Plan`. Caller can inspect `plan.total_cost_usd` and
  decide whether to `execute_plan(plan)`.

### nw.workflow.prepare_shot(project, shot_id, , upload=True)

Resolve all local inputs for rendering a shot.

No billable fal calls. When `upload=True` (the default), local files
are uploaded to fal-storage so the planner can build a Plan with stable
URLs (uploads are free; the cache key derived from those URLs is honest).
When `upload=False` (e.g. for tests or dry-run reporting), the URL
fields are left empty.

Idempotent in spirit but not byte-stable: fal-storage URLs include
expiring signatures, so two `prepare_shot` calls on the same project
produce different URLs. The local file paths are byte-stable.

* **Parameters:**
  * **project** ([`Project`](_autosummary/nw.project.html.md#nw.project.Project)) – An [`nw.Project`](_autosummary/nw.html.md#nw.Project) instance.
  * **shot_id** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str)) – The shot’s id, as in `project.read_spec().shots[*].id`.
  * **upload** ([`bool`](https://docs.python.org/3/builtins/functions.html#bool)) – When True (default), upload local files to fal-storage and
    populate the `*_url` fields. When False, only the local paths
    are populated.
* **Return type:**
  [`ShotPreparation`](_autosummary/nw.workflow.html.md#nw.workflow.ShotPreparation)
* **Returns:**
  A [`ShotPreparation`](_autosummary/nw.workflow.html.md#nw.workflow.ShotPreparation) with local paths (and URLs if `upload`)
  ready to plan.


# about-this-build.html.md

<!-- generated by epythet -->

# About this build

This documentation was built on **2026-09-27 11:23 UTC** from commit <a href="https://github.com/thorwhalen/nw/commit/49602f1e4d7ee5ae9e524aa6ca772bf861267db1"><code>49602f1</code></a> on branch <code>main</code>, for **nw 0.0.62** (from <code>pyproject.toml</code>).

#### NOTE
Nothing suggests a mismatch: the tree was clean at the commit above, and the documented version is the one on PyPI.

## Source

|                     |                                                                                                                                                      |
|---------------------|------------------------------------------------------------------------------------------------------------------------------------------------------|
| Commit              | <a href="https://github.com/thorwhalen/nw/commit/49602f1e4d7ee5ae9e524aa6ca772bf861267db1"><code>49602f1e4d7ee5ae9e524aa6ca772bf861267db1</code></a> |
| Branch              | <code>main</code>                                                                                                                                    |
| Tags at this commit | <code>0.0.62</code>                                                                                                                                  |
| Working tree        | clean                                                                                                                                                |
| Remote              | <code>https://github.com/thorwhalen/nw</code>                                                                                                        |

## Continuous integration

|              |                                                                                            |
|--------------|--------------------------------------------------------------------------------------------|
| Repository   | <code>thorwhalen/nw</code>                                                                 |
| Run          | <a href="https://github.com/thorwhalen/nw/actions/runs/36315416405">36315416405</a>        |
| Ref          | <code>refs/heads/main</code>                                                               |
| Event commit | <code>b5855d210c358c30758e5137d4de829382bc59eb</code> (in the history of the built commit) |

## Tools

|          |         |
|----------|---------|
| epythet  | 0.2.12  |
| Sphinx   | 9.1.0   |
| docutils | 0.22.4  |
| Python   | 3.12.14 |

## Configuration as resolved

|               |                                                                  |
|---------------|------------------------------------------------------------------|
| theme         | <code>auto</code> (Sphinx theme <code>shibuya</code>)            |
| accent        | <code>#26691b</code>                                             |
| api_generator | <code>autosummary</code>                                         |
| ignore        | <code>tests/</code>, <code>scrap/</code>, <code>examples/</code> |
| agent_outputs | <code>true</code>                                                |
| aggregates    | <code>md</code>                                                  |
| ai_artifacts  | <code>true</code>                                                |

## Package on PyPI

Latest release: <a href="https://pypi.org/project/nw/0.0.62/">0.0.62</a>, the same as the documented version.

## Reproduce

```bash
git clone https://github.com/thorwhalen/nw && cd nw
git checkout 49602f1e4d7ee5ae9e524aa6ca772bf861267db1
pip install "epythet==0.2.12"
epythet quickstart . --ignore tests/ scrap/ examples/
```

The same data, for machines: <a href="build_info.json"><code>build_info.json</code></a> (schema version 1).


# ai-agents.html.md

<!-- generated by epythet -->

# For AI agents

`nw` ships artifacts for coding agents alongside its code. This page lists
them, says where each lives in the repository, and points at the
machine-readable copies of this documentation.

## Instruction files

Files agents read before working in this repository.

- [`CLAUDE.md`](https://github.com/thorwhalen/nw/tree/HEAD/CLAUDE.md): read by Claude Code

## Machine-readable documentation

This site publishes the same documentation in forms that fit an agent’s context window:

- [`llms.txt`](https://thorwhalen.github.io/nw/llms.txt): an index of every page with a one-line description ([llms.txt](https://llmstxt.org) format)
- [`nw.md`](https://thorwhalen.github.io/nw/nw.md): the whole documentation as one Markdown file
- `<page>.html.md`: a rendered Markdown twin of every page, advertised from each page’s `<head>` with `<link rel="alternate" type="text/markdown">`
- [`objects.inv`](https://thorwhalen.github.io/nw/objects.inv): the Sphinx inventory: a symbol-to-URL index (`sphobjinv convert plain objects.inv -`)


# api.html.md

# API reference

| [`nw`](_autosummary/nw.html.md#module-nw)   | nw — Narrative Workflow.   |
|-----------------------------------------------------------------|----------------------------|


