Skip to content

zettelkasten.review

zettelkasten.review

Literature-review overlay: the editorial layer over the claim/paper engine.

Where :mod:zettelkasten.papers exposes scored papers and :mod:zettelkasten.claims the claim read layer, this module assembles a review — a curated, theme-structured narrative over the existing corpus — and the durable editorial overlay an author layers on top of it. Like its siblings it is parametrized by a get_graph callable and is pure given its inputs; the only side effects live in :func:create_review / :func:update_review, which persist a _reviews/<name>.yaml manifest through the crash-safe substrate (atomic write + per-review lock + debounced commit).

The design separates two strictly different things:

  • The projection (:func:assemble_review) — a deterministic, LLM-free PROJECTION of the corpus: themes (reusing :func:syllabus.theme_model), the ranked key claims (reusing :mod:zettelkasten.claims) grouped by theme, each theme's holding_pen (in-theme papers engaged with no claim) and completeness. It owns no editorial state and is recomputed from scratch on every call.
  • The overlay (the manifest's overlay block) — the author's editorial decisions stored as ID-REFERENCES, never a copy of the projection: per-claim tiering, exclusions, narrative ordering, section_renames, and author-inserted scaffolding (notes/questions/todos as first-class ordered items).

:func:reconcile (via :func:reproject + :func:apply_overlay) merges the two: it preserves editorial edits whose referenced ids still exist, drops overlay entries whose ids vanished upstream (recorded as removed_upstream rather than crashing), and surfaces projection entities with no overlay placement as unplaced. A regenerate therefore never silently loses editorial work and never crashes on a deleted upstream id.

Claim membership is explicit-edge-only (a claim belongs to whatever its support/contradiction edges connect it to — there is no embedding-inferred claim membership); theme/paper membership reuses :func:theme_model.

assemble_review

assemble_review(get_graph: GetGraph, *, project: str = '', graph: str = '', graphs_dir: 'Path | None' = None, namespace: Callable[[str], str] | None = None, localize: Callable[[str], str] | None = None, scope_name: str = '') -> dict[str, Any]

The review-core PROJECTION over the existing corpus (no LLM, no writes).

Builds the corpus + theme model exactly as :func:papers.assemble_papers does (build_corpus/_graph_scoped_corpus → :func:theme_model with relation_edges/landscape_hubs/anchor_scores), ranks the key claims (:func:claims.key_claims), groups them by theme (via each claim's core/home/supporting papers), and per theme produces the theme's claims, a holding_pen (in-theme papers engaged with no claim), and a completeness ratio (claim-engaged theme papers ÷ all theme papers).

Deterministic and JSON-serializable. Claims whose papers sit in no theme go in the top-level unplaced bucket (the untagged band).

Source code in zettelkasten/review.py
def assemble_review(
    get_graph: GetGraph,
    *,
    project: str = "",
    graph: str = "",
    graphs_dir: "Path | None" = None,
    namespace: Callable[[str], str] | None = None,
    localize: Callable[[str], str] | None = None,
    scope_name: str = "",
) -> dict[str, Any]:
    """The review-core PROJECTION over the existing corpus (no LLM, no writes).

    Builds the corpus + theme model exactly as :func:`papers.assemble_papers`
    does (``build_corpus``/``_graph_scoped_corpus`` → :func:`theme_model` with
    ``relation_edges``/``landscape_hubs``/``anchor_scores``), ranks the key
    claims (:func:`claims.key_claims`), groups them by theme (via each claim's
    core/home/supporting papers), and per theme produces the theme's claims,
    a ``holding_pen`` (in-theme papers engaged with no claim), and a
    ``completeness`` ratio (claim-engaged theme papers ÷ all theme papers).

    Deterministic and JSON-serializable. Claims whose papers sit in no theme go
    in the top-level ``unplaced`` bucket (the untagged band).
    """
    base = graphs_dir or GRAPHS_DIR
    ns = namespace or (lambda s: s)
    loc = localize or (lambda s: s)

    if graph:
        works, scoped = _graph_scoped_corpus(get_graph, graph, graphs_dir=base, namespace=ns)
    else:
        works, scoped = build_corpus(get_graph, project=project, graphs_dir=base, namespace=ns)

    work_ids = {w.id for w in works}
    relation_edges = _derive_relation_edges(get_graph, scoped, loc)
    landscape_hubs = _derive_landscape_hubs(get_graph, scoped, loc, work_ids)
    citations = load_citations(graphs_dir=base)

    cindex = build_claim_index(
        get_graph, project=project, graph=graph, graphs_dir=base,
        namespace=ns, localize=loc,
    )
    # BASE (pre-claim) paper importance, keyed by id AND source-graph folder, so
    # the claim layer's supporter-quality / core-paper selection matches the
    # Papers view and does not feed back into the salience it is computing.
    base_importance = _paper_importance(works)
    centrality = claim_centrality(cindex)
    # No banding/limit → every resolved claim, globally ranked by salience.
    ranked = key_claims(cindex, importance_map=base_importance, centrality=centrality)
    core_map, core_work_id = enrich_works_with_claims(
        works, cindex,
        key_claim_keys=[kc.key for kc in ranked],
        citations=citations,
        importance_map=base_importance,
    )

    anc = anchor_score(works)
    tm = theme_model(
        works,
        relation_edges=relation_edges,
        landscape_hubs=landscape_hubs or None,
        anchor_scores=anc,
        # Promote mints a landscape hub; without this the FIRST hub would flip the
        # whole review to landscape-only, dropping every cluster/cold-start theme
        # and orphaning their claims. Merge keeps the uncovered works themed.
        merge_remainder=True,
    )

    by_source_graph = {w.source_graph: w for w in works if w.source_graph}
    by_id = {w.id: w for w in works}
    assignment = tm.assignment

    claimed_ids = _claimed_work_ids(cindex.resolved, core_work_id, by_source_graph)

    # Suggested editorial tier per claim, from its GLOBAL salience RANK (terciles)
    # rather than an absolute salience cut — robust when absolute salience clusters
    # (e.g. a corpus with sparse edges where most claims sit near 0.5). ``ranked``
    # is already sorted by salience desc, so the top third → core, the middle →
    # supporting, the tail → background. The author can apply or ignore it.
    n_ranked = len(ranked)

    def _suggested_tier(idx: int) -> str:
        if n_ranked <= 0:
            return "supporting"
        frac = idx / n_ranked
        if frac < 1 / 3:
            return "core"
        if frac < 2 / 3:
            return "supporting"
        return "background"

    entries_by_theme: dict[str, list[dict[str, Any]]] = {}
    unplaced: list[dict[str, Any]] = []
    for idx, kc in enumerate(ranked):
        rc = cindex.resolved[kc.key]
        entry = _claim_entry(kc, rc, core_map.get(kc.key), by_source_graph)
        entry["suggested_tier"] = _suggested_tier(idx)
        theme = _assign_claim_theme(rc, core_work_id.get(kc.key), assignment, by_source_graph)
        if theme:
            entries_by_theme.setdefault(theme, []).append(entry)
        else:
            unplaced.append(entry)

    themes_payload: list[dict[str, Any]] = []
    for t in tm.themes:
        theme_papers = list(t.work_ids)
        holding = sorted(w for w in theme_papers if w not in claimed_ids)
        n_claimed = sum(1 for w in theme_papers if w in claimed_ids)
        completeness = (n_claimed / len(theme_papers)) if theme_papers else 0.0
        claim_rows = entries_by_theme.get(t.theme_id, [])
        themes_payload.append({
            "id": t.theme_id,
            "label": t.label,
            "claims": claim_rows,
            "holding_pen": [_paper_ref(by_id[w]) for w in holding if w in by_id],
            "completeness": round(completeness, 4),
            "claim_count": len(claim_rows),
            # The priority tier that produced this theme: ``landscape`` (a real
            # ``_cross`` concept hub, on-disk soft-deletable) vs ``cluster`` /
            # ``cold-start`` (a derived grouping with no hub — removable only via
            # overlay suppression). Carried so consumers can pick the right delete
            # path per theme (hybrid reviews mix both kinds).
            "source": t.source,
        })

    # ``scope.name`` must be the SCOPED ENTITY's id (project/graph), since the
    # dashboard uses it as the path segment for the table/build/org endpoints
    # (``/projects/<name>/...``). Fall back to the review's own name only for an
    # unscoped (full-corpus) review, where there is no project/graph to address.
    name = graph or project or scope_name or "all"
    return {
        "scope": {"type": "graph" if graph else "project", "name": name},
        "theme_source": tm.source,
        "themes": themes_payload,
        "unplaced": unplaced,
    }

reproject

reproject(get_graph: GetGraph, manifest: dict[str, Any], *, graphs_dir: 'Path | None' = None, namespace: Callable[[str], str] | None = None, localize: Callable[[str], str] | None = None) -> dict[str, Any]

Recompute the fresh projection for a manifest's scope (no overlay).

Source code in zettelkasten/review.py
def reproject(
    get_graph: GetGraph,
    manifest: dict[str, Any],
    *,
    graphs_dir: "Path | None" = None,
    namespace: Callable[[str], str] | None = None,
    localize: Callable[[str], str] | None = None,
) -> dict[str, Any]:
    """Recompute the fresh projection for a manifest's scope (no overlay)."""
    return assemble_review(
        get_graph,
        project=str(manifest.get("project") or ""),
        graph=str(manifest.get("graph") or ""),
        graphs_dir=graphs_dir,
        namespace=namespace,
        localize=localize,
        scope_name=str(manifest.get("name") or ""),
    )

apply_overlay

apply_overlay(projection: dict[str, Any], overlay: Any, *, split_derived: bool = False) -> dict[str, Any]

Merge a stored overlay onto a fresh projection — the durability merge.

Preserves every overlay edit whose referenced id STILL EXISTS in the projection; DROPS overlay entries whose referenced id vanished upstream (collected into removed_upstream — never a crash); and SURFACES projection claims that still need authorial attention into unplaced (the worklist of not-yet-fully-placed claims). The id-keying is robust to ids that no longer exist on either side, so a regenerate can neither lose editorial work nor raise on a deleted upstream id.

When split_derived is set (the opt-in theming path used by :func:reconcile), DERIVED (cluster / cold-start) themes are lifted OUT of the placed themes list into suggested_themes and their claims fall back to unplaced; only landscape hubs and proposed drafts stay placed. The default (False) preserves the historical behavior — every derived theme is placed — so structural consumers like the outline gatherer are unaffected. suggested_themes is always present (an empty list unless split_derived lifts any), keeping the returned dict backward-compatible.

Invariant: every projected, non-excluded claim ALWAYS retains at least one payload row — in its theme if themed, and in unplaced until it is fully placed (themed AND positioned by ordering). ordering controls narrative ORDER only; it never determines membership/visibility. In particular an UNTAGGED claim (with no theme home) is never suppressed from unplaced merely because it carries an ordering ref — that bug let default-seeded untagged claims vanish into a dangling reference.

The returned dict is a DERIVED, READ-ONLY VIEW. It must NEVER be persisted back to disk: only the stored overlay (mutated by :func:create_review / :func:update_review) is ever written. Writing this view back would bake the transient projection into the durable manifest and could destroy editorial work on a degenerate/partial load.

Source code in zettelkasten/review.py
def apply_overlay(
    projection: dict[str, Any], overlay: Any, *, split_derived: bool = False
) -> dict[str, Any]:
    """Merge a stored overlay onto a fresh projection — the durability merge.

    Preserves every overlay edit whose referenced id STILL EXISTS in the
    projection; DROPS overlay entries whose referenced id vanished upstream
    (collected into ``removed_upstream`` — never a crash); and SURFACES
    projection claims that still need authorial attention into ``unplaced`` (the
    worklist of not-yet-fully-placed claims). The id-keying is robust to ids that
    no longer exist on either side, so a regenerate can neither lose editorial
    work nor raise on a deleted upstream id.

    When ``split_derived`` is set (the opt-in theming path used by
    :func:`reconcile`), DERIVED (cluster / cold-start) themes are lifted OUT of
    the placed ``themes`` list into ``suggested_themes`` and their claims fall
    back to ``unplaced``; only ``landscape`` hubs and proposed drafts stay placed.
    The default (``False``) preserves the historical behavior — every derived
    theme is placed — so structural consumers like the outline gatherer are
    unaffected. ``suggested_themes`` is always present (an empty list unless
    ``split_derived`` lifts any), keeping the returned dict backward-compatible.

    Invariant: every projected, non-excluded claim ALWAYS retains at least one
    payload row — in its theme if themed, and in ``unplaced`` until it is fully
    placed (themed AND positioned by ``ordering``). ``ordering`` controls
    narrative ORDER only; it never determines membership/visibility. In
    particular an UNTAGGED claim (with no theme home) is never suppressed from
    ``unplaced`` merely because it carries an ordering ref — that bug let
    default-seeded untagged claims vanish into a dangling reference.

    The returned dict is a DERIVED, READ-ONLY VIEW. It must NEVER be persisted
    back to disk: only the stored overlay (mutated by :func:`create_review` /
    :func:`update_review`) is ever written. Writing this view back would bake the
    transient projection into the durable manifest and could destroy editorial
    work on a degenerate/partial load.
    """
    overlay = _normalize_overlay(overlay)
    ns = _resolve_overlay_namespaces(projection, overlay)

    # Transient/degenerate-projection guard: if the fresh projection is EMPTY
    # (no themes AND no claims) while the overlay carries real editorial work,
    # this is almost certainly a flaky/partial load (graph not loaded this run,
    # an upstream error) rather than a genuine upstream deletion. Sweeping every
    # overlay ref into ``removed_upstream`` here would present weeks of authoring
    # as "removed". Instead, preserve the overlay verbatim, report nothing as
    # removed, and flag the view ``stale`` so consumers know it is degenerate.
    # Projection emptiness is about the CORPUS projection only — proposed claims
    # are overlay drafts and must not mask a genuinely degenerate/flaky load.
    projection_empty = not projection.get("themes", []) and not ns.projection_claim_uids
    overlay_nonempty = bool(
        overlay["tiering"]
        or overlay["exclusions"]
        or _normalize_ordering(overlay["ordering"])
        or overlay["section_renames"]
        or overlay["scaffolding"]
        or overlay.get("placements")
        or overlay.get("favorites")
        or ns.proposed_raw
        or ns.proposed_themes_raw
    )
    if projection_empty and overlay_nonempty:
        return _stale_overlay_view(projection, overlay, ns)

    # Keep only the overlay refs whose ids still resolve (dropped ones are
    # reported as ``removed_upstream``), then rebuild the theme sections and the
    # unplaced worklist from the surviving state.
    refs = _filter_surviving_refs(overlay, ns)
    themes_out, themed_emitted = _assemble_theme_sections(
        projection, ns, refs, split_derived=split_derived
    )
    unplaced = _assemble_unplaced(
        projection, ns, refs, themed_emitted, split_derived=split_derived
    )
    suggested_themes = (
        _assemble_suggested_themes(projection, ns, refs) if split_derived else []
    )

    return {
        "scope": projection.get("scope", {}),
        "theme_source": projection.get("theme_source"),
        "themes": themes_out,
        # Derived (cluster / cold-start) tiers as OPT-IN suggestions — NOT placed
        # in ``themes``; their claims fall back to ``unplaced`` until accepted.
        "suggested_themes": suggested_themes,
        "ordering": refs.ordering,
        "scaffolding": [dict(s) for s in overlay["scaffolding"] if isinstance(s, dict)],
        "tiering": refs.tiering,
        "exclusions": refs.exclusions,
        "section_renames": refs.renames,
        "section_notes": refs.notes,
        "placements": refs.placements,
        "favorites": refs.favorites,
        "proposed_claims": [dict(pc) for pc in ns.proposed_raw],
        "proposed_themes": [dict(pt) for pt in ns.proposed_themes_raw],
        "unplaced": unplaced,
        "removed_upstream": refs.removed_upstream,
        "undoable_promote": _undoable_promote(overlay),
        "has_saved_structure": bool(overlay.get("saved_structure")),
        "pristine": _overlay_is_pristine(overlay),
        # The live (resolving) suppressed-derived-theme ids, so the UI can offer a
        # "restore" affordance and round-trip the set.
        "suppressed_themes": sorted(ns.suppressed_themes),
        # The live (resolving) explicitly-unplaced claim uids — round-tripped so the
        # client can reconcile its optimistic "return to Unplaced" overrides.
        "unplaced_claims": sorted(refs.forced_unplaced),
        "stale": False,
    }

reconcile

reconcile(get_graph: GetGraph, name: str | None = None, *, manifest: dict[str, Any] | None = None, outline_id: str = '', graphs_dir: 'Path | None' = None, namespace: Callable[[str], str] | None = None, localize: Callable[[str], str] | None = None, split_derived: bool = True) -> dict[str, Any]

Load (or accept) a manifest, reproject, and apply an outline's overlay.

The top-level read path for a stored review: returns the reconciled state the UI/consumers render — fresh projection with the editorial overlay merged on top (see :func:apply_overlay). Pass either name (to load _reviews/<name>.yaml) or an in-memory manifest.

outline_id selects WHICH outline's structure to reconcile. The default (empty / "outline") uses the manifest overlay verbatim — identical to the historical behavior. An additional outline id merges the shared review-wide overlay (tiering/exclusions/favorites, from the manifest) with that outline's own per-outline structure (from the sidecar) and reports the outline's own title/question. The result carries outline_id so consumers know which outline they are looking at.

The returned state is a DERIVED, READ-ONLY VIEW and must NEVER be written back to the manifest. Only the stored overlay (mutated by :func:create_review / :func:update_review) is persisted; persisting this reconciled view would bake a transient projection into the durable overlay and could lose editorial work (see the staleness guard in :func:apply_overlay, which keys the stale flag on the result).

split_derived selects the theming policy (opt-in split, see :func:apply_overlay). It DEFAULTS to True — the builder/board policy the dashboard and the outline gatherer render, where DERIVED (cluster / cold-start) tiers are lifted out of themes into suggested_themes so the exported document matches the left pane 1:1. NON-builder MCP consumers that treat derived tiers as existing centroids — the review load read path and propose_placements' REBUILD clustering — pass split_derived=False to keep every derived theme placed (their historical behavior), so they do not re-cluster already-derived groupings from scratch.

Source code in zettelkasten/review.py
def reconcile(
    get_graph: GetGraph,
    name: str | None = None,
    *,
    manifest: dict[str, Any] | None = None,
    outline_id: str = "",
    graphs_dir: "Path | None" = None,
    namespace: Callable[[str], str] | None = None,
    localize: Callable[[str], str] | None = None,
    split_derived: bool = True,
) -> dict[str, Any]:
    """Load (or accept) a manifest, reproject, and apply an outline's overlay.

    The top-level read path for a stored review: returns the reconciled state
    the UI/consumers render — fresh projection with the editorial overlay merged
    on top (see :func:`apply_overlay`). Pass either ``name`` (to load
    ``_reviews/<name>.yaml``) or an in-memory ``manifest``.

    ``outline_id`` selects WHICH outline's structure to reconcile. The default
    (empty / ``"outline"``) uses the manifest overlay verbatim — identical to the
    historical behavior. An additional outline id merges the shared review-wide
    overlay (tiering/exclusions/favorites, from the manifest) with that outline's
    own per-outline structure (from the sidecar) and reports the outline's own
    title/question. The result carries ``outline_id`` so consumers know which
    outline they are looking at.

    The returned state is a DERIVED, READ-ONLY VIEW and must NEVER be written
    back to the manifest. Only the stored overlay (mutated by
    :func:`create_review` / :func:`update_review`) is persisted; persisting this
    reconciled view would bake a transient projection into the durable overlay
    and could lose editorial work (see the staleness guard in
    :func:`apply_overlay`, which keys the ``stale`` flag on the result).

    ``split_derived`` selects the theming policy (opt-in split, see
    :func:`apply_overlay`). It DEFAULTS to ``True`` — the builder/board policy the
    dashboard and the outline gatherer render, where DERIVED (cluster / cold-start)
    tiers are lifted out of ``themes`` into ``suggested_themes`` so the exported
    document matches the left pane 1:1. NON-builder MCP consumers that treat
    derived tiers as existing centroids — the ``review load`` read path and
    ``propose_placements``' REBUILD clustering — pass ``split_derived=False`` to
    keep every derived theme placed (their historical behavior), so they do not
    re-cluster already-derived groupings from scratch.
    """
    from zettelkasten import outlines

    base = graphs_dir or GRAPHS_DIR
    if manifest is None:
        if name is None:
            raise ValueError("reconcile requires either a review name or a manifest")
        manifest = load_review(name, graphs_dir=base)
    projection = reproject(
        get_graph, manifest, graphs_dir=base, namespace=namespace, localize=localize
    )
    review_name = str(manifest.get("name") or name or "")
    oid = outline_id or outlines.DEFAULT_OUTLINE_ID
    if oid != outlines.DEFAULT_OUTLINE_ID:
        overlay = outlines.merged_overlay(review_name, oid, graphs_dir=base, manifest=manifest)
        rec = outlines.get_outline(review_name, oid, graphs_dir=base, manifest=manifest)
        title = (rec or {}).get("title") or manifest.get("title") or ""
        question = (rec or {}).get("question") or manifest.get("question") or ""
    else:
        overlay = manifest.get("overlay")
        title = manifest.get("title") or ""
        question = manifest.get("question") or ""
    # Opt-in theming: the BUILDER/BOARD surfaces (the default) place only
    # landscape hubs and proposed drafts, surfacing derived (cluster / cold-start)
    # tiers under ``suggested_themes`` for the author to accept — this is what the
    # left pane and the gathered document render, so the two match 1:1. NON-builder
    # MCP consumers (``review load``; ``propose_placements`` REBUILD) pass
    # ``split_derived=False`` so they keep treating derived tiers as existing,
    # placed centroids rather than re-clustering them from scratch.
    state = apply_overlay(projection, overlay, split_derived=split_derived)
    state["name"] = review_name
    state["title"] = str(title)
    state["question"] = str(question)
    state["outline_id"] = oid
    return state

create_review

create_review(name: str, *, title: str | None = None, project: str = '', graph: str = '', question: str | None = None, graphs_dir: 'Path | None' = None, get_graph: GetGraph | None = None) -> dict[str, Any]

Create (or return) the _reviews/<name>.yaml manifest — idempotent.

A fresh review starts PRISTINE: an empty overlay with no seeded ordering, so its default structure reports as unbuilt and the dashboard shows the blank "build a structure" state. (get_graph is accepted for call-site compatibility but no longer used at creation.) If the review already exists it is returned unchanged except that a supplied title/question is updated WITHOUT clobbering the overlay. All writes go through the substrate (per-review lock → atomic write → scheduled commit).

Source code in zettelkasten/review.py
def create_review(
    name: str,
    *,
    title: str | None = None,
    project: str = "",
    graph: str = "",
    question: str | None = None,
    graphs_dir: "Path | None" = None,
    get_graph: GetGraph | None = None,
) -> dict[str, Any]:
    """Create (or return) the ``_reviews/<name>.yaml`` manifest — idempotent.

    A fresh review starts PRISTINE: an empty overlay with no seeded ordering, so
    its default structure reports as unbuilt and the dashboard shows the blank
    "build a structure" state. (``get_graph`` is accepted for call-site
    compatibility but no longer used at creation.) If the review already exists it
    is returned unchanged except that a supplied ``title``/``question`` is updated
    WITHOUT clobbering the overlay. All writes go through the substrate
    (per-review lock → atomic write → scheduled commit).
    """
    validate_id(name, kind="review name", for_filename=True)
    base = Path(graphs_dir) if graphs_dir is not None else GRAPHS_DIR
    with review_write_lock(name, graphs_dir=base):
        existing = load_review(name, graphs_dir=base)
        if existing:
            changed = False
            if title is not None and existing.get("title") != title:
                existing["title"] = title
                changed = True
            if question is not None and existing.get("question") != question:
                existing["question"] = question
                changed = True
            if changed:
                _persist_manifest(name, existing, base)
            return existing

        # A new review starts PRISTINE (empty overlay) — the structure is built
        # deliberately via the Structure wizard, so we no longer auto-seed an
        # ordering from the projection. ``_overlay_is_pristine`` then reports the
        # default structure as unbuilt and the dashboard shows the blank
        # "build a structure" state instead of the raw auto-projection.
        overlay = _empty_overlay()

        manifest = {
            "name": name,
            "title": title or name,
            "project": project or "",
            "graph": graph or "",
            "question": question or "",
            "overlay": overlay,
        }
        _persist_manifest(name, manifest, base)
        return manifest

update_review

update_review(name: str, *, title: str | None = None, question: str | None = None, project: str | None = None, graph: str | None = None, ordering: list[Any] | None = None, set_tier: Any = None, exclude: Any = None, unexclude: Any = None, add_scaffold: dict[str, Any] | None = None, remove_scaffold: Any = None, rename_section: Any = None, set_section_note: Any = None, set_placement: Any = None, favorite: Any = None, unfavorite: Any = None, reset_structure: bool = False, save_structure: bool = False, restore_structure: bool = False, add_proposed_claim: dict[str, Any] | None = None, remove_proposed_claim: Any = None, add_proposed_theme: dict[str, Any] | None = None, remove_proposed_theme: Any = None, remove_derived_theme: Any = None, restore_derived_theme: Any = None, promote_proposed_theme: dict[str, Any] | None = None, undo_promote: bool = False, set_section_framing: dict[str, Any] | None = None, set_apex: Any = _UNSET, outline_id: str = '', graphs_dir: 'Path | None' = None) -> dict[str, Any]

Apply one or more overlay mutations to an existing review and persist.

Supported mutations (any combination, all optional):

  • title / question — manifest scalars.
  • project / graph — the projection SCOPE scalars. Passing None leaves a scalar unchanged; passing "" clears it (back to the full corpus). Changing scope re-projects on the next reconcile.
  • ordering — replace the narrative order (reorder).
  • set_tier{uid: tier} or (uid, tier); a None tier clears.
  • exclude / unexclude — add/remove claim/paper id(s) from exclusions (an excluded id is also pulled out of the ordering).
  • add_scaffold{"kind", "text", "id"?, "position"?}; appends (or inserts at position) a scaffolding item AND an ordering ref for it.
  • remove_scaffold — scaffold id(s) to remove (also pulled from ordering).
  • rename_section{theme_id: label} or (theme_id, label); a None label clears the rename.
  • set_section_note{theme_id: text} or (theme_id, text); sets the theme's freeform author note. A None/empty text clears it.
  • set_placement{uid: theme_id} or (uid, theme_id); places a claim into a theme (drag-to-place) and clears any standing unplaced marker. A None theme_id RETURNS THE CLAIM TO THE UNPLACED WORKLIST: it drops the placement override AND records an explicit unplaced_claims marker, so a projection-homed claim stays unplaced instead of snapping back to its projection theme.
  • favorite / unfavorite — claim uid(s) to star / unstar.
  • reset_structure — when truthy, snaps the theme layout back to the engine's projection: clears ALL placements and section renames, and drops every claim and section ordering ref (scaffold refs are preserved so author interjections keep their narrative spots). Tiers, favorites, exclusions, scaffolds, and proposed claims/themes are left intact. The _cross graph is untouched — this only resets the overlay's structure.
  • save_structure — when truthy, captures the CURRENT theme layout (placements, section renames, ordering, and the detach-carried section_framing / apex) into overlay.saved_structure as a restore point. Overwrites any previous snapshot (single-slot). The human-defined counterpart to the engine projection that reset_structure snaps back to.
  • restore_structure — when truthy, reapplies the layout captured by the most recent save_structure (placements, section renames, ordering, and the detach-carried section_framing / apex), leaving the snapshot itself intact so it can be restored again. Tiers, favorites, exclusions, scaffolds, and proposed items are untouched. Raises ValueError if there is no saved structure to restore.
  • add_proposed_claim{"title", "body"?, "theme"?, "tier"?, "id"?, "position"?}; appends an OVERLAY-ONLY proposed claim (never a _cross node) and an ordering ref for it, optionally placing it into theme and tiering it. Reusing the same id overwrites that proposed claim's title.
  • remove_proposed_claim — proposed-claim id(s) to delete from the overlay entirely (the structure-only delete), pulling any placement / ordering / tier / favorite / exclusion ref along with it.
  • add_proposed_theme{"title", "body"?, "members"?, "id"?, "position"?, "claims"?}; appends an OVERLAY-ONLY proposed SECTION (never a _cross landscape hub) and a section ordering ref for it, optionally placing the claim uids in claims into it. Reusing the same id overwrites that section's title/body/members.
  • remove_proposed_theme — proposed-theme id(s) to delete from the overlay entirely, pulling its ordering ref and rename along with it and returning every claim placed into it to its projection home / unplaced (the placements pointing at it are cleared).
  • remove_derived_theme — DERIVED (cluster / cold-start / hybrid-remainder) theme id(s) to SUPPRESS. A derived theme has no _cross hub to soft-delete and is recomputed from the corpus each projection, so it cannot be deleted on disk; suppression hides the section at reconcile (the section analogue of an exclusion). Mirrors remove_proposed_theme's structural cleanup: drops the section's ordering ref + rename + note and returns every claim placed into it to its projection home / unplaced. The claims are never deleted. Reversible via restore_derived_theme. Use the on-disk delete_theme for a real landscape hub, not this.
  • restore_derived_theme — derived theme id(s) to UN-suppress, bringing the section back into the view (the reverse of remove_derived_theme).
  • promote_proposed_theme{"theme_id", "hub_id", "claim_remap"?}; the OVERLAY half of promoting a draft section to a real landscape hub (the caller has already materialized the _cross hub + claims). Drops the proposed theme and the promoted draft claims, RE-POINTS the section's ordering ref + rename + every placement from theme_id to hub_id, and RE-KEYS each promoted draft claim's placement/ordering/tier/favorite/ exclusion from its draft id to its materialized uid (claim_remap maps draft_id → new_uid). The graph writes happen in the producer; this only reconciles the overlay so the now-real hub keeps the draft's editorial state.
  • undo_promote — when truthy, reverses the OVERLAY half of the most recent promote by restoring the overlay_before snapshot stashed in overlay.promote_undo (and clearing it). The caller (route) has already discarded the materialized _cross hub + claims. Single-level: only the most recent promote is undoable, and the restore discards overlay edits made after that promote. Raises ValueError if there is nothing to undo.
  • set_section_framing{theme_id: {"concepts": [uid,...], "fallback": [uid,...], "order": [uid,...]}}; REPLACES the per-outline spine-DERIVED framing snapshot (an empty dict clears it). Carried by a "Detach from spine" so the settled export can rebuild each section's concept tags + claim-sparse fallback units live, and restore the within-section unit order (order) the live spine export used. Omitted (None) → no change.
  • set_apex — the outline-level apex synthesis-root framing dict (or None to clear it); re-surfaced on the settled export's scope for a detached outline. Omitted (the _UNSET sentinel) → no change.

The read-modify-write is wrapped in :func:review_write_lock and persisted atomically via the substrate. Raises FileNotFoundError if the review does not exist.

Source code in zettelkasten/review.py
def update_review(
    name: str,
    *,
    title: str | None = None,
    question: str | None = None,
    project: str | None = None,
    graph: str | None = None,
    ordering: list[Any] | None = None,
    set_tier: Any = None,
    exclude: Any = None,
    unexclude: Any = None,
    add_scaffold: dict[str, Any] | None = None,
    remove_scaffold: Any = None,
    rename_section: Any = None,
    set_section_note: Any = None,
    set_placement: Any = None,
    favorite: Any = None,
    unfavorite: Any = None,
    reset_structure: bool = False,
    save_structure: bool = False,
    restore_structure: bool = False,
    add_proposed_claim: dict[str, Any] | None = None,
    remove_proposed_claim: Any = None,
    add_proposed_theme: dict[str, Any] | None = None,
    remove_proposed_theme: Any = None,
    remove_derived_theme: Any = None,
    restore_derived_theme: Any = None,
    promote_proposed_theme: dict[str, Any] | None = None,
    undo_promote: bool = False,
    set_section_framing: dict[str, Any] | None = None,
    set_apex: Any = _UNSET,
    outline_id: str = "",
    graphs_dir: "Path | None" = None,
) -> dict[str, Any]:
    """Apply one or more overlay mutations to an existing review and persist.

    Supported mutations (any combination, all optional):

    * ``title`` / ``question`` — manifest scalars.
    * ``project`` / ``graph`` — the projection SCOPE scalars. Passing ``None``
      leaves a scalar unchanged; passing ``""`` clears it (back to the full
      corpus). Changing scope re-projects on the next reconcile.
    * ``ordering`` — replace the narrative order (reorder).
    * ``set_tier`` — ``{uid: tier}`` or ``(uid, tier)``; a ``None`` tier clears.
    * ``exclude`` / ``unexclude`` — add/remove claim/paper id(s) from exclusions
      (an excluded id is also pulled out of the ordering).
    * ``add_scaffold`` — ``{"kind", "text", "id"?, "position"?}``; appends (or
      inserts at ``position``) a scaffolding item AND an ordering ref for it.
    * ``remove_scaffold`` — scaffold id(s) to remove (also pulled from ordering).
    * ``rename_section`` — ``{theme_id: label}`` or ``(theme_id, label)``; a
      ``None`` label clears the rename.
    * ``set_section_note`` — ``{theme_id: text}`` or ``(theme_id, text)``; sets the
      theme's freeform author note. A ``None``/empty text clears it.
    * ``set_placement`` — ``{uid: theme_id}`` or ``(uid, theme_id)``; places a
      claim into a theme (drag-to-place) and clears any standing unplaced marker.
      A ``None`` theme_id RETURNS THE CLAIM TO THE UNPLACED WORKLIST: it drops the
      placement override AND records an explicit ``unplaced_claims`` marker, so a
      projection-homed claim stays unplaced instead of snapping back to its
      projection theme.
    * ``favorite`` / ``unfavorite`` — claim uid(s) to star / unstar.
    * ``reset_structure`` — when truthy, snaps the theme layout back to the
      engine's projection: clears ALL placements and section renames, and drops
      every ``claim`` and ``section`` ordering ref (scaffold refs are preserved
      so author interjections keep their narrative spots). Tiers, favorites,
      exclusions, scaffolds, and proposed claims/themes are left intact. The
      ``_cross`` graph is untouched — this only resets the overlay's structure.
    * ``save_structure`` — when truthy, captures the CURRENT theme layout
      (placements, section renames, ordering, and the detach-carried
      ``section_framing`` / ``apex``) into ``overlay.saved_structure`` as a restore
      point. Overwrites any previous snapshot (single-slot). The human-defined
      counterpart to the engine projection that ``reset_structure`` snaps back to.
    * ``restore_structure`` — when truthy, reapplies the layout captured by the
      most recent ``save_structure`` (placements, section renames, ordering, and
      the detach-carried ``section_framing`` / ``apex``), leaving the snapshot
      itself intact so it can be restored again. Tiers, favorites, exclusions,
      scaffolds, and proposed items are untouched. Raises ``ValueError`` if there
      is no saved structure to restore.
    * ``add_proposed_claim`` — ``{"title", "body"?, "theme"?, "tier"?, "id"?,
      "position"?}``; appends an OVERLAY-ONLY proposed claim (never a ``_cross``
      node) and an ordering ref for it, optionally placing it into ``theme`` and
      tiering it. Reusing the same ``id`` overwrites that proposed claim's title.
    * ``remove_proposed_claim`` — proposed-claim id(s) to delete from the overlay
      entirely (the structure-only delete), pulling any placement / ordering /
      tier / favorite / exclusion ref along with it.
    * ``add_proposed_theme`` — ``{"title", "body"?, "members"?, "id"?,
      "position"?, "claims"?}``; appends an OVERLAY-ONLY proposed SECTION (never a
      ``_cross`` landscape hub) and a ``section`` ordering ref for it, optionally
      placing the claim uids in ``claims`` into it. Reusing the same ``id``
      overwrites that section's title/body/members.
    * ``remove_proposed_theme`` — proposed-theme id(s) to delete from the overlay
      entirely, pulling its ordering ref and rename along with it and returning
      every claim placed into it to its projection home / unplaced (the
      placements pointing at it are cleared).
    * ``remove_derived_theme`` — DERIVED (``cluster`` / ``cold-start`` /
      hybrid-remainder) theme id(s) to SUPPRESS. A derived theme has no ``_cross``
      hub to soft-delete and is recomputed from the corpus each projection, so it
      cannot be deleted on disk; suppression hides the section at reconcile (the
      section analogue of an exclusion). Mirrors ``remove_proposed_theme``'s
      structural cleanup: drops the section's ordering ref + rename + note and
      returns every claim placed into it to its projection home / unplaced. The
      claims are never deleted. Reversible via ``restore_derived_theme``. Use the
      on-disk ``delete_theme`` for a real ``landscape`` hub, not this.
    * ``restore_derived_theme`` — derived theme id(s) to UN-suppress, bringing the
      section back into the view (the reverse of ``remove_derived_theme``).
    * ``promote_proposed_theme`` — ``{"theme_id", "hub_id", "claim_remap"?}``; the
      OVERLAY half of promoting a draft section to a real landscape hub (the
      caller has already materialized the ``_cross`` hub + claims). Drops the
      proposed theme and the promoted draft claims, RE-POINTS the section's
      ordering ref + rename + every placement from ``theme_id`` to ``hub_id``, and
      RE-KEYS each promoted draft claim's placement/ordering/tier/favorite/
      exclusion from its draft id to its materialized uid (``claim_remap`` maps
      ``draft_id → new_uid``). The graph writes happen in the producer; this only
      reconciles the overlay so the now-real hub keeps the draft's editorial state.
    * ``undo_promote`` — when truthy, reverses the OVERLAY half of the most recent
      promote by restoring the ``overlay_before`` snapshot stashed in
      ``overlay.promote_undo`` (and clearing it). The caller (route) has already
      discarded the materialized ``_cross`` hub + claims. Single-level: only the
      most recent promote is undoable, and the restore discards overlay edits made
      after that promote. Raises ``ValueError`` if there is nothing to undo.
    * ``set_section_framing`` — ``{theme_id: {"concepts": [uid,...],
      "fallback": [uid,...], "order": [uid,...]}}``; REPLACES the per-outline
      spine-DERIVED framing snapshot (an empty dict clears it). Carried by a
      "Detach from spine" so the settled export can rebuild each section's concept
      tags + claim-sparse fallback units live, and restore the within-section unit
      order (``order``) the live spine export used. Omitted (``None``) → no change.
    * ``set_apex`` — the outline-level apex synthesis-root framing dict (or
      ``None`` to clear it); re-surfaced on the settled export's ``scope`` for a
      detached outline. Omitted (the ``_UNSET`` sentinel) → no change.

    The read-modify-write is wrapped in :func:`review_write_lock` and persisted
    atomically via the substrate. Raises ``FileNotFoundError`` if the review
    does not exist.
    """
    from zettelkasten import outlines as _outlines

    validate_id(name, kind="review name", for_filename=True)
    base = Path(graphs_dir) if graphs_dir is not None else GRAPHS_DIR
    is_default = (not outline_id) or outline_id == _outlines.DEFAULT_OUTLINE_ID
    with review_write_lock(name, graphs_dir=base):
        manifest = load_review(name, graphs_dir=base)
        if not manifest:
            raise FileNotFoundError(f"Review '{name}' does not exist.")
        overlay = _resolve_update_overlay(
            name, manifest, outline_id, is_default, base, _outlines
        )

        _apply_update_scalars(manifest, is_default, title, question, project, graph)

        # Overlay mutations, applied in a fixed order (each is a no-op unless its
        # argument is supplied). The order is significant — e.g. an ``exclude``
        # pull runs after ``ordering`` is replaced — so it mirrors the historical
        # branch sequence exactly.
        _edit_ordering(overlay, ordering)
        _edit_tiering(overlay, set_tier)
        _edit_exclusions(overlay, exclude, unexclude)
        _edit_scaffolding(overlay, add_scaffold, remove_scaffold)
        _edit_section_metadata(overlay, rename_section, set_section_note)
        _edit_placements(overlay, set_placement)
        _edit_favorites(overlay, favorite, unfavorite)
        _edit_structure(overlay, reset_structure, save_structure, restore_structure)
        _edit_proposed_claims(overlay, add_proposed_claim, remove_proposed_claim)
        _edit_proposed_themes(overlay, add_proposed_theme, remove_proposed_theme)
        _edit_derived_themes(overlay, remove_derived_theme, restore_derived_theme)
        # Spine-DERIVED framing snapshot (a "Detach from spine" carries it). Runs
        # AFTER ``_edit_structure`` so a combined reset+write in one call clears
        # first, then writes the fresh snapshot.
        _edit_section_framing(overlay, set_section_framing, set_apex)
        # ``undo_promote`` may replace the overlay wholesale, so rebind it.
        overlay = _edit_promotion(overlay, promote_proposed_theme, undo_promote)

        return _persist_review_update(
            name, manifest, overlay, outline_id, is_default, title, question, base, _outlines
        )

detach_spine

detach_spine(get_graph: Any, name: str, *, outline_id: str = '', project: str = '', graph: str = '', graphs_dir: 'Path | None' = None, namespace: 'Callable[[str], str] | None' = None, localize: 'Callable[[str], str] | None' = None) -> dict[str, Any]

Snapshot an outline's SPINE partition into its overlay, then clear the spine.

Turns a live, spine-organized outline into an editable THEME outline: the spine's dimension sections are materialized into the overlay as proposed themes + single-home placements, then the outline's spine ref is cleared so the board overlay (no longer overridden by the spine) becomes the authoritative structure.

The snapshot is taken from the SAME gather the spine view renders (:func:zettelkasten.outline.gather_outline_material with the spine active). Parity is asymmetric by kind: the detached export reproduces the spine export's FRAMING — concept tags, claim-sparse fallback units, the apex, and the within-section unit order — 1:1, but grounded CLAIMS are deliberately SINGLE-HOMED. The live spine export MULTI-homes a claim (it can appear in every dimension whose membership carries it); the editable overlay's placements map is single-valued, so each claim is collapsed FIRST-WINS in dimension order into exactly one section. That multi-membership→single-home collapse is the one accepted lossy step of detach, surfaced to the user in the detach confirmation.

  • Sections — one proposed theme per spine dimension, in the export's dimension order (the matrix column order), each keyed by a DETERMINISTIC id (proposed-theme-<node_id>) so a re-run overwrites rather than duplicates. The section label is the dimension's label; empty dimensions become empty sections (matching the spine export's skeleton).
  • Membership — a spine is MULTI-membership (a claim can belong to dimensions A and B); the overlay placements map is SINGLE-home. Each GROUNDED claim is collapsed FIRST-WINS in dimension order (deterministic), so it lands in exactly one section.
  • Order — per-claim ordering refs are written in the export's within-section narrative order, so the settled branch renders each section in the SAME order rather than bare projection order.
  • Derived framing — spine-DERIVED framing (per-dimension concept tags, claim-sparse fallback units, and the apex synthesis root) is ALSO carried, so the detached outline reproduces the spine export's FRAMING, not just its claims. Framing units cannot ride placements at all — placements maps a grounded-CLAIM uid to a single section, whereas concept tags and fallback bundles are DERIVED rendering artifacts, not claim placements — so the detach records a per-section section_framing snapshot (concept-tag note uids + claim-sparse fallback unit uids + the within-section unit order, keyed by the settled section id) plus the outline-level apex; the settled export rebuilds those units LIVE from the note keys and restores the captured order (see the settled branch of :func:zettelkasten.outline.gather_outline_material), skipping any uid whose note no longer resolves. The order carry matters because the live spine export narrative-orders claims and framing fallback units TOGETHER (a synthesis fallback can sit BEFORE a claim), whereas the settled branch keeps the board's authoritative claim order and would otherwise append framing units last — so without order the detached within-section order would DIVERGE from the live export. Concepts are SINGLE-HOMED in both the live spine export and this snapshot: the live export adds each concept to the shared placed set, so it surfaces only in the FIRST dimension whose membership carries it, and this snapshot inherits that (it records exactly what material.sections shows) — matching the live spine export is the parity target. The SOURCE notes are untouched and keep projecting in sibling outlines and on the review board.

Replace, not append + recovery. Before writing the snapshot the outline's structural overlay (layout + every pre-existing proposed section) is CLEARED, so no dormant board-edited section survives the detach and a retry re-derives cleanly from the live spine (a membership change between a failed clear and a retry can never mis-home a claim). The overlay write happens FIRST, then the spine ref is cleared. If the clear fails after the overlay write, the state stays RECOVERABLE: the overlay sits DORMANT under the still-active spine, and a retry re-clears + re-writes the same (idempotent) snapshot and finishes the clear — never a half-materialized loss.

Raises FileNotFoundError if the review / outline does not exist, and ValueError if the outline has no spine or the spine ref does not resolve to a materialized spine in this scope (nothing to detach).

Source code in zettelkasten/review.py
def detach_spine(
    get_graph: Any,
    name: str,
    *,
    outline_id: str = "",
    project: str = "",
    graph: str = "",
    graphs_dir: "Path | None" = None,
    namespace: "Callable[[str], str] | None" = None,
    localize: "Callable[[str], str] | None" = None,
) -> dict[str, Any]:
    """Snapshot an outline's SPINE partition into its overlay, then clear the spine.

    Turns a live, spine-organized outline into an editable THEME outline: the
    spine's dimension sections are materialized into the overlay as proposed
    themes + single-home ``placements``, then the outline's ``spine`` ref is
    cleared so the board overlay (no longer overridden by the spine) becomes the
    authoritative structure.

    The snapshot is taken from the SAME gather the spine view renders
    (:func:`zettelkasten.outline.gather_outline_material` with the spine active).
    Parity is asymmetric by kind: the detached export reproduces the spine export's
    FRAMING — concept tags, claim-sparse fallback units, the apex, and the
    within-section unit order — 1:1, but grounded CLAIMS are deliberately
    SINGLE-HOMED. The live spine export MULTI-homes a claim (it can appear in every
    dimension whose membership carries it); the editable overlay's ``placements``
    map is single-valued, so each claim is collapsed FIRST-WINS in dimension order
    into exactly one section. That multi-membership→single-home collapse is the one
    accepted lossy step of detach, surfaced to the user in the detach confirmation.

    * **Sections** — one proposed theme per spine dimension, in the export's
      dimension order (the matrix column order), each keyed by a DETERMINISTIC id
      (``proposed-theme-<node_id>``) so a re-run overwrites rather than duplicates.
      The section label is the dimension's label; empty dimensions become empty
      sections (matching the spine export's skeleton).
    * **Membership** — a spine is MULTI-membership (a claim can belong to
      dimensions A and B); the overlay ``placements`` map is SINGLE-home. Each
      GROUNDED claim is collapsed FIRST-WINS in dimension order (deterministic),
      so it lands in exactly one section.
    * **Order** — per-claim ``ordering`` refs are written in the export's
      within-section narrative order, so the settled branch renders each section
      in the SAME order rather than bare projection order.
    * **Derived framing** — spine-DERIVED framing (per-dimension concept tags,
      claim-sparse fallback units, and the apex synthesis root) is ALSO carried,
      so the detached outline reproduces the spine export's FRAMING, not just its
      claims. Framing units cannot ride ``placements`` at all — ``placements`` maps
      a grounded-CLAIM uid to a single section, whereas concept tags and fallback
      bundles are DERIVED rendering artifacts, not claim placements — so the detach
      records a per-section ``section_framing`` snapshot (concept-tag note uids +
      claim-sparse fallback unit uids + the within-section unit ``order``, keyed by
      the settled section id) plus the outline-level ``apex``; the settled export
      rebuilds those units LIVE from the note keys and restores the captured
      ``order`` (see the settled branch of
      :func:`zettelkasten.outline.gather_outline_material`), skipping any uid
      whose note no longer resolves. The ``order`` carry matters because the live
      spine export narrative-orders claims and framing fallback units TOGETHER (a
      ``synthesis`` fallback can sit BEFORE a claim), whereas the settled branch
      keeps the board's authoritative claim order and would otherwise append
      framing units last — so without ``order`` the detached within-section order
      would DIVERGE from the live export. Concepts are SINGLE-HOMED in both the live
      spine export and this snapshot: the live export adds each concept to the
      shared placed set, so it surfaces only in the FIRST dimension whose
      membership carries it, and this snapshot inherits that (it records exactly
      what ``material.sections`` shows) — matching the live spine export is the
      parity target. The SOURCE notes are untouched and keep projecting in sibling
      outlines and on the review board.

    **Replace, not append + recovery.** Before writing the snapshot the outline's
    structural overlay (layout + every pre-existing proposed section) is CLEARED,
    so no dormant board-edited section survives the detach and a retry re-derives
    cleanly from the live spine (a membership change between a failed clear and a
    retry can never mis-home a claim). The overlay write happens FIRST, then the
    spine ref is cleared. If the clear fails after the overlay write, the state
    stays RECOVERABLE: the overlay sits DORMANT under the still-active spine, and a
    retry re-clears + re-writes the same (idempotent) snapshot and finishes the
    clear — never a half-materialized loss.

    Raises ``FileNotFoundError`` if the review / outline does not exist, and
    ``ValueError`` if the outline has no spine or the spine ref does not resolve
    to a materialized spine in this scope (nothing to detach).
    """
    from zettelkasten import outlines as _outlines
    from zettelkasten.outline import gather_outline_material

    validate_id(name, kind="review name", for_filename=True)
    base = Path(graphs_dir) if graphs_dir is not None else GRAPHS_DIR
    oid = outline_id or _outlines.DEFAULT_OUTLINE_ID

    manifest = load_review(name, graphs_dir=base)
    if not manifest:
        raise FileNotFoundError(f"Review '{name}' does not exist.")
    rec = _outlines.get_outline(name, oid, graphs_dir=base, manifest=manifest)
    if rec is None:
        raise FileNotFoundError(f"Outline '{oid}' of review '{name}' does not exist.")
    spine = rec.get("spine")
    spine_mode = rec.get("spine_mode") or ""
    if not spine:
        raise ValueError("This outline has no spine to detach.")

    # Snapshot the LIVE spine export — the SAME gather the spine view renders — so
    # the detached overlay reproduces its sections, membership, and per-section
    # narrative ORDER 1:1 (re-deriving from the raw partition would carry no
    # narrative order). A ref that no longer resolves to a materialized spine
    # degrades to the default partition (no ``scope['spine']``) — nothing to detach.
    material = gather_outline_material(
        get_graph, project=project, graph=graph, name=name, outline_id=oid,
        spine=spine, spine_mode=spine_mode, graphs_dir=base, namespace=namespace, localize=localize,
    )
    if not material.scope.get("spine"):
        raise ValueError(
            f"Spine '{spine}' does not resolve to a materialized spine in this scope."
        )

    # Walk the export in dimension (matrix-column) order. GROUNDED claims are
    # carried as single-home ``placements`` — FIRST-WINS in dimension order
    # (``placements`` is single-valued per uid) and in the export's within-section
    # narrative order. Spine-DERIVED framing (per-dimension concept tags +
    # claim-sparse fallback units) CANNOT ride placements (they are derived
    # rendering artifacts, not grounded-claim placements), so it is snapshotted per
    # section into ``section_framing`` (concept-tag / fallback unit uids, keyed by
    # the settled section id) EXACTLY as the live spine export laid it out — which
    # SINGLE-HOMES each concept (the first dimension whose membership carries it),
    # so this snapshot is single-homed too, NOT replicated. The settled export
    # rebuilds those units live from the note keys. The SOURCE notes are untouched
    # and keep projecting elsewhere, so nothing is deleted review-wide.
    section_specs: list[tuple[str, str, list[str]]] = []  # (theme_id, label, claim_uids)
    section_framing: dict[str, dict[str, list[str]]] = {}  # theme_id -> framing uids
    first_home: dict[str, str] = {}
    homed_order: list[str] = []  # grounded-claim uids in export (narrative) order
    for sec in material.sections:
        theme_id = f"proposed-theme-{sec.theme_id}"
        claims_here: list[str] = []
        fallback_uids: list[str] = []
        for bundle in sec.claims:
            if bundle.source != "claim":
                # Claim-sparse fallback unit — carried as framing (in
                # ``section_framing``), not as a grounded-claim placement.
                fallback_uids.append(bundle.uid)
                continue
            if bundle.uid in first_home:
                continue  # already homed in an earlier dimension (first-wins)
            first_home[bundle.uid] = theme_id
            homed_order.append(bundle.uid)
            claims_here.append(bundle.uid)
        section_specs.append((theme_id, sec.label, claims_here))
        # Record each section's concept/fallback framing EXACTLY as the live spine
        # export laid it out. The export SINGLE-HOMES concepts (each concept is
        # placed in only the first dimension whose membership carries it), so
        # ``material.sections`` already reflects that single-homing and this
        # snapshot inherits it — no replication (that would DIVERGE from the live
        # export and break parity). Only record non-empty sections so a claim-only
        # spine leaves ``section_framing`` empty (settled export unchanged).
        concept_uids = [tag.uid for tag in sec.concepts]
        if concept_uids or fallback_uids:
            section_framing[theme_id] = {
                "concepts": concept_uids,
                "fallback": fallback_uids,
                # The section's WITHIN-SECTION unit order (claims + framing fallback
                # units) EXACTLY as the live spine export narrative-ordered it: the
                # export interleaves a framing fallback (e.g. a ``synthesis``) among
                # the claims by narrative rank, so a fallback can sit BEFORE a claim.
                # The settled re-layer restores this interleaving from ``order``
                # rather than appending framing units last (see the settled branch
                # of :func:`zettelkasten.outline.gather_outline_material`). Concepts
                # are NOT here — they render as separate section tags, not inline
                # units — so ``order`` covers only the ``sec.claims`` sequence.
                "order": [b.uid for b in sec.claims],
            }
    # The outline-level apex synthesis root (rides on ``scope['spine']['apex']`` in
    # the live export). Re-surfaced on the settled export's scope after detach.
    apex = (material.scope.get("spine") or {}).get("apex")

    # ── overlay write FIRST (recoverable if the spine clear later fails) ───────
    # (1) REPLACE, not append: clear this outline's structural overlay — the layout
    #     (placements / renames / non-scaffold ordering) AND every pre-existing
    #     proposed section — so no dormant board-edited section survives and a retry
    #     after a failed spine-clear re-derives cleanly (stale placements gone, so a
    #     membership change between attempts can never mis-home a claim).
    prior_overlay = (
        _outlines.merged_overlay(name, oid, graphs_dir=base, manifest=manifest) or {}
    )
    prior_theme_ids = [
        str(pt.get("id"))
        for pt in (prior_overlay.get("proposed_themes") or [])
        if isinstance(pt, dict) and pt.get("id")
    ]
    update_review(
        name,
        reset_structure=True,
        remove_proposed_theme=prior_theme_ids or None,
        outline_id=outline_id,
        graphs_dir=base,
    )
    # (2) One proposed section per dimension (in order), homing its grounded claims.
    #     Empty dimensions become empty sections (the spine skeleton).
    for theme_id, label, claims_here in section_specs:
        update_review(
            name,
            add_proposed_theme={
                "id": theme_id,
                "title": label,
                "claims": claims_here or None,
            },
            outline_id=outline_id,
            graphs_dir=base,
        )
    # (3) Pin the section order (dimension order) + per-claim narrative order via
    #     ordering refs so the settled branch renders each section in the SAME order
    #     the spine export used. ``ordering=`` REPLACES the whole list, so re-include
    #     any author ``scaffold`` refs that ``reset_structure`` (step 1) preserved —
    #     otherwise an author interjection (note/question/todo) loses its pinned
    #     narrative slot on detach. Scaffolds are appended after the freshly-derived
    #     section/claim sequence (their old interleaving with the pre-detach sections
    #     is meaningless once the sections are rebuilt from the spine), keeping their
    #     relative order among themselves.
    scaffold_refs: list[dict[str, str]] = [
        {"kind": "scaffold", "ref": str(o.get("ref"))}
        for o in _normalize_ordering(prior_overlay.get("ordering"))
        if isinstance(o, dict) and o.get("kind") == "scaffold" and o.get("ref")
    ]
    ordering_refs: list[dict[str, str]] = [
        {"kind": "section", "ref": theme_id} for theme_id, _label, _claims in section_specs
    ]
    ordering_refs += [{"kind": "claim", "ref": uid} for uid in homed_order]
    ordering_refs += scaffold_refs
    # Pin the ordering AND persist the spine-DERIVED framing snapshot (per-section
    # concept/fallback uids + apex) in the SAME overlay write — durable BEFORE the
    # spine clear below, so an interrupted detach stays recoverable. ``reset_structure``
    # (step 1) already cleared any prior snapshot, so this fully REPLACES it.
    update_review(
        name,
        ordering=ordering_refs,
        set_section_framing=section_framing,
        set_apex=apex,
        outline_id=outline_id,
        graphs_dir=base,
    )

    # ── THEN clear the spine ref (the point of no return; overlay already durable)
    _outlines.set_outline_spine(name, oid, None, graphs_dir=base)

    return {
        "name": name,
        "outline_id": oid,
        "detached_spine": spine,
        "sections": [theme_id for theme_id, _label, _claims in section_specs],
    }

attach_spine_members

attach_spine_members(get_graph: Any, name: str, *, spine: str, members: 'list[dict[str, Any]]', outline_id: str = '', project: str = '', graph: str = '', graphs_dir: 'Path | None' = None, namespace: 'Callable[[str], str] | None' = None, localize: 'Callable[[str], str] | None' = None) -> dict[str, Any]

Attach author-placed claims to a spine's dimension nodes — WRITES.

The complement of a spine-organized board being a READ-ONLY VIEW of the spine (its sections come from spine membership, not the board overlay): when the Structure builder builds an outline with a spine as its section axis, the claims the author placed under each dimension theme are routed HERE into that spine dimension as spine-member edges (dim --spine-member--> note@home), so they read back into the board's dimension section instead of being discarded. Editing a spine-organized board therefore means editing the spine — exactly what this does.

Membership is stored SPINE-SIDE (§11 of the spine-promotion design), matching :func:organizations._attach_members: each edge is an OUTGOING cross-graph link FROM the dimension node IN THE SPINE GRAPH to the member's base note (target_graph = the claim's home). The base note is never mutated. The write is idempotent (SpineGraphOps.ensure_link dedupes) and ADDITIVE — it never removes membership — so re-running (e.g. re-attaching a claim already a member) is a no-op.

members is a list of {"dimension": <node_id>, "claims": [{"id","graph"}, ...]} groups. A group whose dimension node does not resolve in the spine graph, or a claim missing id/graph, is skipped (counted in skipped). Returns {"spine", "attached", "skipped"}.

Raises FileNotFoundError if the review does not exist and ValueError if spine is empty or does not resolve to a materialized spine in scope.

Source code in zettelkasten/review.py
def attach_spine_members(
    get_graph: Any,
    name: str,
    *,
    spine: str,
    members: "list[dict[str, Any]]",
    outline_id: str = "",
    project: str = "",
    graph: str = "",
    graphs_dir: "Path | None" = None,
    namespace: "Callable[[str], str] | None" = None,
    localize: "Callable[[str], str] | None" = None,
) -> dict[str, Any]:
    """Attach author-placed claims to a spine's dimension nodes — WRITES.

    The complement of a spine-organized board being a READ-ONLY VIEW of the spine
    (its sections come from spine membership, not the board overlay): when the
    Structure builder builds an outline with a spine as its section axis, the
    claims the author placed under each dimension theme are routed HERE into that
    spine dimension as ``spine-member`` edges (``dim --spine-member--> note@home``),
    so they read back into the board's dimension section instead of being
    discarded. Editing a spine-organized board therefore means editing the spine —
    exactly what this does.

    Membership is stored SPINE-SIDE (§11 of the spine-promotion design), matching
    :func:`organizations._attach_members`: each edge is an OUTGOING cross-graph
    link FROM the dimension node IN THE SPINE GRAPH to the member's base note
    (``target_graph`` = the claim's home). The base note is never mutated. The
    write is idempotent (``SpineGraphOps.ensure_link`` dedupes) and ADDITIVE — it
    never removes membership — so re-running (e.g. re-attaching a claim already a
    member) is a no-op.

    ``members`` is a list of ``{"dimension": <node_id>, "claims": [{"id","graph"}, ...]}``
    groups. A group whose dimension node does not resolve in the spine graph, or a
    claim missing ``id``/``graph``, is skipped (counted in ``skipped``). Returns
    ``{"spine", "attached", "skipped"}``.

    Raises ``FileNotFoundError`` if the review does not exist and ``ValueError``
    if ``spine`` is empty or does not resolve to a materialized spine in scope.
    """
    from zettelkasten import spine as _spine
    from zettelkasten.outline.gather import _resolve_spine_org

    validate_id(name, kind="review name", for_filename=True)
    base = Path(graphs_dir) if graphs_dir is not None else GRAPHS_DIR

    manifest = load_review(name, graphs_dir=base)
    if not manifest:
        raise FileNotFoundError(f"Review '{name}' does not exist.")

    spine = (spine or "").strip()
    if not spine:
        raise ValueError("A spine organization id is required to attach members.")

    org = _resolve_spine_org(spine, project=project, graph=graph, base=base)
    if org is None or str(org.get("state") or "") != "spine":
        raise ValueError(
            f"Spine '{spine}' does not resolve to a materialized spine in this scope."
        )
    spine_graph = str(org.get("spine_ref") or "").strip()
    if not spine_graph:
        raise ValueError(f"Spine '{spine}' has no synthesis graph to attach into.")

    # The dimension node must EXIST in the spine graph — ``ensure_link`` is a
    # silent no-op on a missing source note, so verify up front to report an
    # honest ``skipped`` count rather than a false ``attached``.
    try:
        sg = get_graph(spine_graph)
    except Exception as exc:  # noqa: BLE001 — an unreadable spine graph attaches nothing
        raise ValueError(f"Spine graph '{spine_graph}' is not readable.") from exc
    known_nodes = set(getattr(sg, "notes", {}).keys())

    ops = _spine.SpineGraphOps(get_graph, graphs_dir=base)
    attached = 0
    skipped = 0
    for grp in members or []:
        if not isinstance(grp, dict):
            skipped += 1
            continue
        dim_id = str(grp.get("dimension") or "").strip()
        claims_here = grp.get("claims") or []
        if not dim_id or dim_id not in known_nodes:
            skipped += len(claims_here)
            continue
        for c in claims_here:
            if not isinstance(c, dict):
                skipped += 1
                continue
            nid = str(c.get("id") or "").strip()
            home = str(c.get("graph") or "").strip()
            if not nid or not home:
                skipped += 1
                continue
            if ops.ensure_link(
                spine_graph,
                dim_id,
                nid,
                _spine.MEMBERSHIP_RELATION,
                target_graph=home,
                provenance="author-placed",
            ):
                attached += 1
    return {"spine": spine, "attached": attached, "skipped": skipped}

rename_review

rename_review(name: str, new_name: str, *, graphs_dir: 'Path | None' = None) -> dict[str, Any]

Rename a review's slug — move its on-disk artifacts and re-stamp name.

The slug is the _reviews/<name> filename stem that keys EVERY one of a review's files: the durable manifest (<name>.yaml), the regenerable outline sidecar (<name>.md), and the persisted matrices (<name>.tables.json). A rename therefore moves all three (whichever exist) to the <new_name> stem and updates the manifest's own name field so the in-file id agrees with its filename.

Both names are path-traversal validated. Raises FileNotFoundError if the source review does not exist and ValueError if new_name is already taken (the rename must never clobber another review). A no-op (new_name equal to name) just returns the current manifest.

Both slugs' write locks are held for the move (acquired in a stable sorted order so two concurrent renames can't deadlock), and every moved path — the vacated source AND the new destination — is scheduled for a debounced zettelkasten-mcp commit so the rename is version-controlled exactly like a create/delete pair.

Returns the updated manifest (with the new name).

Source code in zettelkasten/review.py
def rename_review(
    name: str,
    new_name: str,
    *,
    graphs_dir: "Path | None" = None,
) -> dict[str, Any]:
    """Rename a review's slug — move its on-disk artifacts and re-stamp ``name``.

    The slug is the ``_reviews/<name>`` filename stem that keys EVERY one of a
    review's files: the durable manifest (``<name>.yaml``), the regenerable
    outline sidecar (``<name>.md``), and the persisted matrices
    (``<name>.tables.json``). A rename therefore moves all three (whichever
    exist) to the ``<new_name>`` stem and updates the manifest's own ``name``
    field so the in-file id agrees with its filename.

    Both names are path-traversal validated. Raises ``FileNotFoundError`` if the
    source review does not exist and ``ValueError`` if ``new_name`` is already
    taken (the rename must never clobber another review). A no-op (``new_name``
    equal to ``name``) just returns the current manifest.

    Both slugs' write locks are held for the move (acquired in a stable sorted
    order so two concurrent renames can't deadlock), and every moved path — the
    vacated source AND the new destination — is scheduled for a debounced
    ``zettelkasten-mcp`` commit so the rename is version-controlled exactly like
    a create/delete pair.

    Returns the updated manifest (with the new ``name``).
    """
    validate_id(name, kind="review name", for_filename=True)
    validate_id(new_name, kind="review name", for_filename=True)
    base = Path(graphs_dir) if graphs_dir is not None else GRAPHS_DIR
    if new_name == name:
        existing = load_review(name, graphs_dir=base)
        if not existing:
            raise FileNotFoundError(f"Review '{name}' does not exist.")
        return existing

    # Lock BOTH slugs (sorted) so a create/update/rename on either side can't
    # race the move, and two opposite-direction renames can't deadlock.
    first, second = sorted((name, new_name))
    with review_write_lock(first, graphs_dir=base), review_write_lock(
        second, graphs_dir=base
    ):
        from zettelkasten import outlines as _outlines

        manifest = load_review(name, graphs_dir=base)
        if not manifest:
            raise FileNotFoundError(f"Review '{name}' does not exist.")
        if load_review(new_name, graphs_dir=base):
            raise ValueError(f"A review named '{new_name}' already exists.")

        # Capture additional-outline ids BEFORE moving the sidecar, so we can move
        # each outline's namespaced markdown artifact along with it.
        outline_ids = list(_outlines._load_sidecar(name, base).keys())

        reviews_dir = Path(base) / "_reviews"
        # The name-keyed artifacts: manifest, DEFAULT outline markdown, matrices,
        # and the additional-outline registry sidecar.
        suffixes = [".yaml", ".md", ".tables.json", ".outlines.json"]
        # Each additional outline's markdown (``<name>.<oid>.md``) moves too.
        suffixes += [f".{oid}.md" for oid in outline_ids]
        for suffix in suffixes:
            src = reviews_dir / f"{name}{suffix}"
            if not src.exists():
                continue
            dst = reviews_dir / f"{new_name}{suffix}"
            src.rename(dst)
            # Commit both the vacated source (a tracked-but-deleted path) and the
            # new destination so the move lands as one logical rename in git.
            _schedule_zettel_commit(str(src.resolve()))
            _schedule_zettel_commit(str(dst.resolve()))

        # Re-stamp the manifest's own id so it agrees with its new filename, then
        # persist atomically (this re-writes <new_name>.yaml with the fixed name).
        manifest["name"] = new_name
        _persist_manifest(new_name, manifest, base)
        # Keep the org registry in step: re-key any mirrored orgs old→new so the
        # rename doesn't orphan them or seed migration duplicates.
        _rekey_orgs_for_renamed_review(name, new_name, base)
        return manifest

delete_review

delete_review(name: str, *, graphs_dir: 'Path | None' = None) -> dict[str, Any]

Delete a review's manifest (and its regenerable outline sidecar).

Removes the durable _reviews/<name>.yaml overlay AND, when present, the regenerable _reviews/<name>.md outline artifact, then schedules each removed path for a debounced zettelkasten-mcp git commit so the deletion is version-controlled exactly like every other review write (the committer stages a tracked-but-deleted path with git add -A).

The read-modify-delete is wrapped in :func:review_write_lock (the same two-layer thread + cross-process lock the writers take) so a concurrent create/update in the MCP server or the dashboard backend can never race the removal. Idempotent: deleting an absent review is a no-op that returns {"deleted": False} rather than raising. The name is validated against path traversal before any file is touched.

Returns {"name", "deleted", "removed"} where removed lists the repo-relative paths actually unlinked.

Source code in zettelkasten/review.py
def delete_review(
    name: str,
    *,
    graphs_dir: "Path | None" = None,
) -> dict[str, Any]:
    """Delete a review's manifest (and its regenerable outline sidecar).

    Removes the durable ``_reviews/<name>.yaml`` overlay AND, when present, the
    regenerable ``_reviews/<name>.md`` outline artifact, then schedules each
    removed path for a debounced ``zettelkasten-mcp`` git commit so the deletion
    is version-controlled exactly like every other review write (the committer
    stages a tracked-but-deleted path with ``git add -A``).

    The read-modify-delete is wrapped in :func:`review_write_lock` (the same
    two-layer thread + cross-process lock the writers take) so a concurrent
    create/update in the MCP server or the dashboard backend can never race the
    removal. Idempotent: deleting an absent review is a no-op that returns
    ``{"deleted": False}`` rather than raising. The name is validated against
    path traversal before any file is touched.

    Returns ``{"name", "deleted", "removed"}`` where ``removed`` lists the
    repo-relative paths actually unlinked.
    """
    from zettelkasten import outlines as _outlines

    validate_id(name, kind="review name", for_filename=True)
    base = Path(graphs_dir) if graphs_dir is not None else GRAPHS_DIR
    with review_write_lock(name, graphs_dir=base):
        reviews_dir = Path(base) / "_reviews"
        yaml_path = reviews_dir / f"{name}.yaml"
        md_path = reviews_dir / f"{name}.md"
        outlines_path = reviews_dir / f"{name}.outlines.json"
        # Each additional outline's namespaced markdown artifact, if any.
        outline_md = [
            reviews_dir / f"{name}.{oid}.md"
            for oid in _outlines._load_sidecar(name, base)
        ]

        removed: list[str] = []
        for path in (yaml_path, md_path, outlines_path, *outline_md):
            if path.exists():
                resolved = str(path.resolve())
                path.unlink()
                # Schedule AFTER unlink: the committer discovers the removal and
                # commits it (it walks up to an existing ancestor to find the
                # repo and keeps tracked-but-deleted paths in the batch).
                _schedule_zettel_commit(resolved)
                removed.append(f"_reviews/{path.name}")

        # Drop the orgs mirroring this review's tables so the owner switcher
        # doesn't keep showing entries for a review that no longer exists.
        _cleanup_orgs_for_deleted_review(name, base)

        return {"name": name, "deleted": bool(removed), "removed": removed}