Skip to content

zettelkasten.dashboard.backend.routes.reviews

zettelkasten.dashboard.backend.routes.reviews

Literature-review CRUD, claim/theme, grounding, and board routes.

Split out of the former flat routes.py; behaviour is unchanged.

FramingSeed

Bases: BaseModel

A frame_question framing payload used to SEED a new review.

The same shape the read-only /frame lens accepts. When carried on a POST /reviews body it does not project anything itself — it just supplies the review's guiding question (and, when the review's own project is blank, the framing project scope) so a review can be born already framed around a research question.

Source code in zettelkasten/dashboard/backend/routes/reviews.py
class FramingSeed(BaseModel):
    """A ``frame_question`` framing payload used to SEED a new review.

    The same shape the read-only ``/frame`` lens accepts. When carried on a
    ``POST /reviews`` body it does not project anything itself — it just supplies
    the review's guiding ``question`` (and, when the review's own ``project`` is
    blank, the framing ``project`` scope) so a review can be born already framed
    around a research question.
    """

    question: str = ""
    project: str = ""

UpdateReviewRequest

Bases: BaseModel

Overlay mutations for PUT /reviews/{name} (all optional).

Mirrors review.update_review: every field defaults to None (no change) so a partial body applies exactly the mutations it carries.

Source code in zettelkasten/dashboard/backend/routes/reviews.py
class UpdateReviewRequest(BaseModel):
    """Overlay mutations for ``PUT /reviews/{name}`` (all optional).

    Mirrors ``review.update_review``: every field defaults to ``None`` (no
    change) so a partial body applies exactly the mutations it carries.
    """

    # Which outline's structure this edit targets. Empty / "outline" = the
    # default outline (manifest-backed); any other id targets that additional
    # outline's sidecar overlay. Review-wide edits (tier/exclude/favorite) are
    # shared regardless of which outline is selected.
    outline_id: str = ""
    title: str | None = None
    question: str | None = None
    ordering: list | None = None
    set_tier: dict | None = None
    exclude: list[str] | None = None
    unexclude: list[str] | None = None
    add_scaffold: dict | None = None
    remove_scaffold: list[str] | None = None
    rename_section: dict | None = None
    set_section_note: dict | None = None
    set_placement: dict | None = None
    favorite: list[str] | None = None
    unfavorite: list[str] | None = None
    reset_structure: bool | None = None
    save_structure: bool | None = None
    restore_structure: bool | None = None
    add_proposed_claim: dict | None = None
    remove_proposed_claim: list[str] | None = None
    add_proposed_theme: dict | None = None
    remove_proposed_theme: list[str] | None = None
    remove_derived_theme: list[str] | None = None
    restore_derived_theme: list[str] | None = None

EditReviewMetadataRequest

Bases: BaseModel

Scalar metadata edits for a review (PATCH /reviews/{name}/metadata).

Distinct from the overlay PUT: this edits the manifest's OWN scalars — title / question and the projection project / graph scope — and can RENAME the review's slug via new_name (which moves the manifest plus its regenerable .md / .tables.json sidecars on disk). Every field is optional; None means "leave unchanged" while an empty string is a meaningful value for the scope scalars (clears them back to the full corpus).

Source code in zettelkasten/dashboard/backend/routes/reviews.py
class EditReviewMetadataRequest(BaseModel):
    """Scalar metadata edits for a review (``PATCH /reviews/{name}/metadata``).

    Distinct from the overlay ``PUT``: this edits the manifest's OWN scalars —
    ``title`` / ``question`` and the projection ``project`` / ``graph`` scope —
    and can RENAME the review's slug via ``new_name`` (which moves the manifest
    plus its regenerable ``.md`` / ``.tables.json`` sidecars on disk). Every
    field is optional; ``None`` means "leave unchanged" while an empty string is
    a meaningful value for the scope scalars (clears them back to the full
    corpus).
    """

    new_name: str | None = None
    title: str | None = None
    question: str | None = None
    project: str | None = None
    graph: str | None = None

CreateClaimRequest

Bases: BaseModel

Body for POST /reviews/{name}/claims — mirrors claim_producer.create_claim.

The caller supplies the grounded sentence (title + body) and the member refs for the stance edges; the producer never fabricates claim text or members. Omit claim_id to mint a fresh id from the title; pass it to make the write an idempotent create-or-overwrite. body/status are None /"" sentinels for "unset" handled by the producer's read-merge.

Source code in zettelkasten/dashboard/backend/routes/reviews.py
class CreateClaimRequest(BaseModel):
    """Body for ``POST /reviews/{name}/claims`` — mirrors ``claim_producer.create_claim``.

    The caller supplies the grounded sentence (``title`` + ``body``) and the
    member refs for the stance edges; the producer never fabricates claim text or
    members. Omit ``claim_id`` to mint a fresh id from the title; pass it to make
    the write an idempotent create-or-overwrite. ``body``/``status`` are ``None``
    /``""`` sentinels for "unset" handled by the producer's read-merge.
    """

    claim_id: str = ""
    title: str
    body: str | None = None
    supports: list | None = None
    contradicts: list | None = None
    tags: list[str] | None = None
    source: dict | None = None
    status: str = ""
    confidence: float | None = None

PromoteClaimRequest

Bases: BaseModel

Body for POST /reviews/{name}/promote-claim — accept a tested hypothesis.

Mirrors the promote descriptor that claim_producer.test_claim returns: the grounded sentence (title), a stable claim_id for idempotency, the member refs for the supports / contradicts stance edges, and the retrieved verbatim quotes to write as evidence. The caller passes the descriptor's args through unchanged.

Source code in zettelkasten/dashboard/backend/routes/reviews.py
class PromoteClaimRequest(BaseModel):
    """Body for ``POST /reviews/{name}/promote-claim`` — accept a tested hypothesis.

    Mirrors the ``promote`` descriptor that ``claim_producer.test_claim`` returns:
    the grounded sentence (``title``), a stable ``claim_id`` for idempotency, the
    member refs for the ``supports`` / ``contradicts`` stance edges, and the
    retrieved verbatim ``quotes`` to write as evidence. The caller passes the
    descriptor's ``args`` through unchanged.
    """

    claim_id: str = ""
    title: str
    body: str | None = None
    supports: list | None = None
    contradicts: list | None = None
    quotes: list | None = None
    tags: list[str] | None = None
    confidence: float | None = None

GroundClaimRequest

Bases: BaseModel

Body for POST /reviews/{name}/ground-claim — close an evidential gap.

Identifies the under-evidenced _cross claim to ground by re-reading its OWN supporting source documents and extracting verbatim quotes. The route derives the corpus scope from the review manifest, so only the claim id is needed.

Source code in zettelkasten/dashboard/backend/routes/reviews.py
class GroundClaimRequest(BaseModel):
    """Body for ``POST /reviews/{name}/ground-claim`` — close an evidential gap.

    Identifies the under-evidenced ``_cross`` claim to ground by re-reading its
    OWN supporting source documents and extracting verbatim quotes. The route
    derives the corpus scope from the review manifest, so only the claim id is
    needed.
    """

    claim_id: str

GroundPaperRequest

Bases: BaseModel

Body for POST /reviews/{name}/ground-paper — fill a paper's CCC gap.

Identifies the paper whose missing CCC notes (definition / mechanism / numbers / verbatim quotes) the outline's [GAP:<paper-id>] marker flagged. The route derives the corpus scope from the review manifest, so only the paper id (the gather paper_id carried by the marker) is needed.

Source code in zettelkasten/dashboard/backend/routes/reviews.py
class GroundPaperRequest(BaseModel):
    """Body for ``POST /reviews/{name}/ground-paper`` — fill a paper's CCC gap.

    Identifies the paper whose missing CCC notes (definition / mechanism / numbers
    / verbatim quotes) the outline's ``[GAP:<paper-id>]`` marker flagged. The route
    derives the corpus scope from the review manifest, so only the paper id (the
    gather ``paper_id`` carried by the marker) is needed.
    """

    paper_id: str

TestClaimRequest

Bases: BaseModel

Body for POST /reviews/{name}/test-claim — a hypothesis to test.

Source code in zettelkasten/dashboard/backend/routes/reviews.py
class TestClaimRequest(BaseModel):
    """Body for ``POST /reviews/{name}/test-claim`` — a hypothesis to test."""

    text: str
    top_k: int = 0

SuggestClaimsRequest

Bases: BaseModel

Body for POST /reviews/{name}/suggest-claims — a pitched topic.

Source code in zettelkasten/dashboard/backend/routes/reviews.py
class SuggestClaimsRequest(BaseModel):
    """Body for ``POST /reviews/{name}/suggest-claims`` — a pitched topic."""

    text: str
    top_k: int = 0

StructuralThemesRequest

Bases: BaseModel

Body for POST /reviews/{name}/structural-themes — a structural axis.

kind selects the deterministic partition axis (source | note_type | tag | spine); spine names the organization id whose dimensions become themes (REQUIRED when kind == 'spine', ignored otherwise); and include_unplaced appends the claims that matched no group as a trailing "Unplaced" theme.

Source code in zettelkasten/dashboard/backend/routes/reviews.py
class StructuralThemesRequest(BaseModel):
    """Body for ``POST /reviews/{name}/structural-themes`` — a structural axis.

    ``kind`` selects the deterministic partition axis (``source`` | ``note_type``
    | ``tag`` | ``spine``); ``spine`` names the organization id whose dimensions
    become themes (REQUIRED when ``kind == 'spine'``, ignored otherwise); and
    ``include_unplaced`` appends the claims that matched no group as a trailing
    ``"Unplaced"`` theme.
    """

    kind: str
    spine: str = ""
    # Section-partition MODE for an OVERARCHING ``spine`` selection (a
    # ``schema:<hash>`` group or a nested parent): per_dimension / per_subspine /
    # nested. Empty = the single-spine partition (ignored unless kind == 'spine').
    spine_mode: str = ""
    include_unplaced: bool = False

InboundClaimsRequest

Bases: BaseModel

Body for POST /reviews/{name}/inbound-claims — pull claims into a theme.

Source code in zettelkasten/dashboard/backend/routes/reviews.py
class InboundClaimsRequest(BaseModel):
    """Body for ``POST /reviews/{name}/inbound-claims`` — pull claims into a theme."""

    theme_id: str
    # "all" surfaces EVERY claim that fits this theme above the floor — unplaced
    # ones plus every claim currently placed in another theme (each shown with its
    # current placement + fit so the author decides). "unplaced" restricts
    # candidates to the Unplaced worklist only.
    scope: str = "all"

MineClaimsRequest

Bases: BaseModel

Body for POST /reviews/{name}/mine-claims — a pitched topic to mine.

Source code in zettelkasten/dashboard/backend/routes/reviews.py
class MineClaimsRequest(BaseModel):
    """Body for ``POST /reviews/{name}/mine-claims`` — a pitched topic to mine."""

    text: str
    top_k: int = 0

MineSourcesRequest

Bases: BaseModel

Body for POST /reviews/{name}/mine-sources — a theme to find sources for.

Source code in zettelkasten/dashboard/backend/routes/reviews.py
class MineSourcesRequest(BaseModel):
    """Body for ``POST /reviews/{name}/mine-sources`` — a theme to find sources for."""

    theme_id: str

MineAtomicRequest

Bases: BaseModel

Body for POST /reviews/{name}/mine-atomic — sources to extract from.

Source code in zettelkasten/dashboard/backend/routes/reviews.py
class MineAtomicRequest(BaseModel):
    """Body for ``POST /reviews/{name}/mine-atomic`` — sources to extract from."""

    theme_id: str
    graphs: list[str]

PromoteThemeRequest

Bases: BaseModel

Body for POST /reviews/{name}/promote-theme — a draft section to promote.

Source code in zettelkasten/dashboard/backend/routes/reviews.py
class PromoteThemeRequest(BaseModel):
    """Body for ``POST /reviews/{name}/promote-theme`` — a draft section to promote."""

    theme_id: str

DetachSpineRequest

Bases: BaseModel

Body for POST /reviews/{name}/detach-spine — the outline to detach.

outlineId selects WHICH of the review's outlines to detach (empty / "outline" = the manifest-backed default). This is the SHARED CONTRACT the frontend codes against — keep the field name exactly.

Source code in zettelkasten/dashboard/backend/routes/reviews.py
class DetachSpineRequest(BaseModel):
    """Body for ``POST /reviews/{name}/detach-spine`` — the outline to detach.

    ``outlineId`` selects WHICH of the review's outlines to detach (empty /
    "outline" = the manifest-backed default). This is the SHARED CONTRACT the
    frontend codes against — keep the field name exactly.
    """

    outlineId: str = ""

SpineMemberClaim

Bases: BaseModel

A claim to attach to a spine dimension: its note id + graph home.

Source code in zettelkasten/dashboard/backend/routes/reviews.py
class SpineMemberClaim(BaseModel):
    """A claim to attach to a spine dimension: its note ``id`` + ``graph`` home."""

    id: str
    graph: str

SpineMemberGroup

Bases: BaseModel

Claims to attach to one spine dimension (a dimension node id).

Source code in zettelkasten/dashboard/backend/routes/reviews.py
class SpineMemberGroup(BaseModel):
    """Claims to attach to one spine ``dimension`` (a dimension node id)."""

    dimension: str
    claims: list[SpineMemberClaim] = []

AttachSpineMembersRequest

Bases: BaseModel

Body for POST /reviews/{name}/attach-spine-members.

spine is the organization id whose dimensions receive the members; members groups the author-placed claims by dimension node id. outlineId is carried for symmetry (the write targets the shared spine, not one outline).

Source code in zettelkasten/dashboard/backend/routes/reviews.py
class AttachSpineMembersRequest(BaseModel):
    """Body for ``POST /reviews/{name}/attach-spine-members``.

    ``spine`` is the organization id whose dimensions receive the members;
    ``members`` groups the author-placed claims by dimension node id. ``outlineId``
    is carried for symmetry (the write targets the shared spine, not one outline).
    """

    spine: str
    members: list[SpineMemberGroup] = []
    outlineId: str = ""

ExecuteActionRequest

Bases: BaseModel

Body for POST /reviews/{name}/actions — execute a gap/missing-paper action.

Mirrors the action descriptors emitted by analyze_gaps / rank_missing_papers: tool is the producer-loop tool to run (expand_corpus | promote_citation | expand_citations | claim), action is the sub-action for the claim tool (e.g. "test"), and args are that tool's keyword arguments.

Source code in zettelkasten/dashboard/backend/routes/reviews.py
class ExecuteActionRequest(BaseModel):
    """Body for ``POST /reviews/{name}/actions`` — execute a gap/missing-paper action.

    Mirrors the ``action`` descriptors emitted by ``analyze_gaps`` /
    ``rank_missing_papers``: ``tool`` is the producer-loop tool to run
    (``expand_corpus`` | ``promote_citation`` | ``expand_citations`` | ``claim``),
    ``action`` is the sub-action for the ``claim`` tool (e.g. ``"test"``), and
    ``args`` are that tool's keyword arguments.
    """

    tool: str
    action: str = ""
    args: dict = {}

get_reviews

get_reviews() -> list[dict]

List local review manifests plus federated reviews as a read-only overlay.

Mirrors /graphs and /projects: local reviews first, then each configured federated repo's reviews appended with namespaced names (<repo_id>:<name>) and read_only: True (federated reviews are a picker overlay only — they have no write path). LOCAL and FEDERATED rows are both run through :func:_review_summary_row so every row shares one overlay-free summary shape (the full reconciled view is served on demand by GET /reviews/{name}).

Source code in zettelkasten/dashboard/backend/routes/reviews.py
@router.get("/reviews")
def get_reviews() -> list[dict]:
    """List local review manifests plus federated reviews as a read-only overlay.

    Mirrors ``/graphs`` and ``/projects``: local reviews first, then each
    configured federated repo's reviews appended with namespaced names
    (``<repo_id>:<name>``) and ``read_only: True`` (federated reviews are a
    picker overlay only — they have no write path). LOCAL and FEDERATED rows are
    both run through :func:`_review_summary_row` so every row shares one
    overlay-free summary shape (the full reconciled view is served on demand by
    ``GET /reviews/{name}``).
    """
    results = [_review_summary_row(m) for m in list_reviews()]
    repos, _errors = federation.configured_repos()
    for repo in repos:
        try:
            results.extend(
                _review_summary_row(fed, repo=fed.get("repo_id") or repo.id)
                for fed in federation.federated_reviews(repo)
            )
        except OSError as exc:
            logger.warning("Could not list federated reviews for '%s': %s", repo.id, exc)
    return results

get_review

get_review(name: str, outline: str = '', request: Request = None, response: Response = None) -> dict

The reconciled view for a review: fresh projection + applied overlay.

Resolves name via _resolve_ref so a federated (namespaced) review is loaded from its own repo's .zettelkasten/ and projected with that repo's namespace — GET is read-only and allowed on federated reviews. Mirrors the project 404 mapping: an unsafe/unknown name → 404. The returned state is a DERIVED, read-only view (never persisted back).

outline selects which outline's structure to reconcile (empty / "outline" = the default; any other id = that additional outline's sidecar overlay).

ETag / conditional-GET (mirrors the Graph A polled-route fast path): the ETag is derived from :func:_review_reconcile_signature — every input the reconcile reads — so a still-needed refetch after an edit is a cheap bodyless 304 when nothing that feeds the view changed, and a full 200 (with a fresh ETag) the moment it does. Reconcile is a fresh uncached full projection, so the 304 path skips the whole projection + serialization, not just the wire bytes.

Source code in zettelkasten/dashboard/backend/routes/reviews.py
@router.get("/reviews/{name}")
def get_review(
    name: str,
    outline: str = "",
    request: Request = None,
    response: Response = None,
) -> dict:
    """The reconciled view for a review: fresh projection + applied overlay.

    Resolves ``name`` via ``_resolve_ref`` so a federated (namespaced) review is
    loaded from its own repo's ``.zettelkasten/`` and projected with that repo's
    namespace — GET is read-only and allowed on federated reviews. Mirrors the
    project 404 mapping: an unsafe/unknown name → 404. The returned state is a
    DERIVED, read-only view (never persisted back).

    ``outline`` selects which outline's structure to reconcile (empty / "outline"
    = the default; any other id = that additional outline's sidecar overlay).

    ETag / conditional-GET (mirrors the Graph A polled-route fast path): the ETag
    is derived from :func:`_review_reconcile_signature` — every input the reconcile
    reads — so a still-needed refetch after an edit is a cheap bodyless 304 when
    nothing that feeds the view changed, and a full 200 (with a fresh ETag) the
    moment it does. Reconcile is a fresh uncached full projection, so the 304 path
    skips the whole projection + serialization, not just the wire bytes.
    """
    local, repo = _resolve_ref(name)
    base = repo.zettel_dir if repo is not None else GRAPHS_DIR
    try:
        manifest = load_review(local, graphs_dir=base)
    except ValueError as exc:
        # ``load_review`` raises ``ValueError`` for two distinct cases: an
        # unsafe/unknown name (path-jail / validation) → genuinely "not found",
        # and a corrupt on-disk manifest → a 422 with a sanitized message (no
        # path/traceback leak). Distinguish on the marker the corrupt path sets.
        if str(exc).startswith("corrupt review manifest"):
            raise HTTPException(
                status_code=422, detail=f"Review '{name}' manifest is corrupt."
            )
        raise HTTPException(status_code=404, detail=f"Review '{name}' not found.")
    except TypeError:
        raise HTTPException(status_code=404, detail=f"Review '{name}' not found.")
    if not manifest:
        raise HTTPException(status_code=404, detail=f"Review '{name}' not found.")
    _ensure_federated_review_allowed(manifest, repo, name)

    # ETag / conditional-GET on the FRESH signature (computed off the just-loaded
    # manifest + on-disk corpus): a matching If-None-Match short-circuits to a
    # bodyless 304 BEFORE the expensive reconcile below runs.
    cache_key = _review_reconcile_signature(local, base, manifest, outline)
    not_modified = _conditional_etag(request, response, cache_key)
    if not_modified is not None:
        return not_modified

    # LRU layered BELOW the 304 short-circuit: on the 200 / cold-load path, serve
    # the memoized reconciled view when the signature (the SAME cache_key) matches
    # a prior build, skipping the fresh full projection entirely. Keyed strictly on
    # the signature, so any manifest / outline / corpus / citations change moves
    # the key and forces a recompute — the cache can never go stale.
    with _review_cache_lock:
        cached = _review_cache.get(cache_key)
        if cached is not None:
            _review_cache.move_to_end(cache_key)
            return cached

    ns = (lambda s: federation.namespace_id(repo.id, s)) if repo is not None else (lambda s: s)
    loc = lambda d: _resolve_ref(d)[0]
    view = review.reconcile(
        _get_graph,
        name=local,
        manifest=manifest,
        outline_id=outline,
        graphs_dir=base,
        namespace=ns,
        localize=loc,
    )

    with _review_cache_lock:
        _review_cache[cache_key] = view
        _review_cache.move_to_end(cache_key)
        while len(_review_cache) > _REVIEW_CACHE_MAX:
            _review_cache.popitem(last=False)
    return view

get_review_bibliography

get_review_bibliography(name: str) -> dict

Home-source bibliography for a review — BibTeX + an author-year list.

The bibliography is exactly the deduplicated set of HOME SOURCES behind the review's claims (the paper each claim was extracted from), rendered two ways so the client can offer a .bib download and/or a ## References section without re-deriving anything. key_map maps each claim uid to its source's BibTeX key, so the exported markdown can emit \cite{key}.

Read-only, so federated (namespaced) reviews are allowed: name resolves via _resolve_ref and each claim's (namespaced) source is localized before its _meta.yaml is read from the owning repo's base.

Source code in zettelkasten/dashboard/backend/routes/reviews.py
@router.get("/reviews/{name}/bibliography")
def get_review_bibliography(name: str) -> dict:
    """Home-source bibliography for a review — BibTeX + an author-year list.

    The bibliography is exactly the deduplicated set of HOME SOURCES behind the
    review's claims (the paper each claim was extracted from), rendered two ways
    so the client can offer a ``.bib`` download and/or a ``## References`` section
    without re-deriving anything. ``key_map`` maps each claim ``uid`` to its
    source's BibTeX key, so the exported markdown can emit ``\\cite{key}``.

    Read-only, so federated (namespaced) reviews are allowed: ``name`` resolves
    via ``_resolve_ref`` and each claim's (namespaced) source is localized before
    its ``_meta.yaml`` is read from the owning repo's ``base``.
    """
    local, repo = _resolve_ref(name)
    base = repo.zettel_dir if repo is not None else GRAPHS_DIR
    try:
        manifest = load_review(local, graphs_dir=base)
    except ValueError as exc:
        if str(exc).startswith("corrupt review manifest"):
            raise HTTPException(
                status_code=422, detail=f"Review '{name}' manifest is corrupt."
            )
        raise HTTPException(status_code=404, detail=f"Review '{name}' not found.")
    except TypeError:
        raise HTTPException(status_code=404, detail=f"Review '{name}' not found.")
    if not manifest:
        raise HTTPException(status_code=404, detail=f"Review '{name}' not found.")
    _ensure_federated_review_allowed(manifest, repo, name)

    ns = (lambda s: federation.namespace_id(repo.id, s)) if repo is not None else (lambda s: s)
    loc = lambda d: _resolve_ref(d)[0]
    view = review.reconcile(
        _get_graph,
        manifest=manifest,
        graphs_dir=base,
        namespace=ns,
        localize=loc,
    )

    entries, key_map = bibliography.collect_review_sources(
        view, lambda s: load_source_meta(loc(s), graphs_dir=base)
    )
    return {
        "review": name,
        "entry_count": len(entries),
        "bibtex": bibliography.to_bibtex(entries),
        "references_markdown": bibliography.to_formatted(entries),
        "key_map": key_map,
    }

create_review_route

create_review_route(req: CreateReviewRequest) -> dict

Create (idempotent) a local review manifest, optionally framing-seeded.

Federated repos are a read-only overlay — a namespaced name resolving to a configured repo is rejected with 403 BEFORE any write. create_review validates the name (path-traversal guard); a bad name → 400. When a frame framing payload is supplied it seeds the review's question (and the projection project scope when the review left it blank) so the review starts life framed around the research question; explicit question/project on the request always win over the framing seed.

Source code in zettelkasten/dashboard/backend/routes/reviews.py
@router.post("/reviews")
def create_review_route(req: CreateReviewRequest) -> dict:
    """Create (idempotent) a local review manifest, optionally framing-seeded.

    Federated repos are a read-only overlay — a namespaced name resolving to a
    configured repo is rejected with 403 BEFORE any write. ``create_review``
    validates the name (path-traversal guard); a bad name → 400. When a
    ``frame`` framing payload is supplied it seeds the review's ``question``
    (and the projection ``project`` scope when the review left it blank) so the
    review starts life framed around the research question; explicit
    ``question``/``project`` on the request always win over the framing seed.
    """
    local, repo = _resolve_ref(req.name)
    if repo is not None:
        raise HTTPException(status_code=403, detail="Federated repos are read-only.")
    question = req.question
    project = req.project
    if req.frame is not None:
        question = question or req.frame.question
        project = project or req.frame.project
    try:
        return review.create_review(
            local,
            title=req.title or None,
            project=project,
            graph=req.graph,
            question=question or None,
            get_graph=_get_graph,
        )
    except TimeoutError:
        raise HTTPException(
            status_code=503, detail="Review write lock busy; please retry."
        )
    except ValueError as e:
        raise HTTPException(status_code=400, detail=str(e))

update_review_route

update_review_route(name: str, req: UpdateReviewRequest) -> dict

Apply overlay mutations to an existing local review.

Federated (namespaced) targets are read-only → 403 before any write. A bad name → 400 (validate_id); a missing review → 404.

Source code in zettelkasten/dashboard/backend/routes/reviews.py
@router.put("/reviews/{name}")
def update_review_route(name: str, req: UpdateReviewRequest) -> dict:
    """Apply overlay mutations to an existing local review.

    Federated (namespaced) targets are read-only → 403 before any write. A bad
    name → 400 (``validate_id``); a missing review → 404.
    """
    local, repo = _resolve_ref(name)
    if repo is not None:
        raise HTTPException(status_code=403, detail="Federated repos are read-only.")
    try:
        return review.update_review(
            local,
            outline_id=req.outline_id,
            title=req.title,
            question=req.question,
            ordering=req.ordering,
            set_tier=req.set_tier,
            exclude=req.exclude,
            unexclude=req.unexclude,
            add_scaffold=req.add_scaffold,
            remove_scaffold=req.remove_scaffold,
            rename_section=req.rename_section,
            set_section_note=req.set_section_note,
            set_placement=req.set_placement,
            favorite=req.favorite,
            unfavorite=req.unfavorite,
            reset_structure=bool(req.reset_structure),
            save_structure=bool(req.save_structure),
            restore_structure=bool(req.restore_structure),
            add_proposed_claim=req.add_proposed_claim,
            remove_proposed_claim=req.remove_proposed_claim,
            add_proposed_theme=req.add_proposed_theme,
            remove_proposed_theme=req.remove_proposed_theme,
            remove_derived_theme=req.remove_derived_theme,
            restore_derived_theme=req.restore_derived_theme,
        )
    except TimeoutError:
        raise HTTPException(
            status_code=503, detail="Review write lock busy; please retry."
        )
    except ValueError as e:
        raise HTTPException(status_code=400, detail=str(e))
    except FileNotFoundError:
        raise HTTPException(status_code=404, detail=f"Review '{name}' not found.")

edit_review_metadata_route

edit_review_metadata_route(name: str, req: EditReviewMetadataRequest) -> dict

Edit a local review's scalar metadata and/or rename its slug.

Federated (namespaced) targets are read-only → 403 before any write. Scalar edits (title/question/project/graph) are applied first on the current slug, then the rename (if any) moves the files — so the returned manifest reflects the final on-disk state. A bad/colliding name → 400; a missing review → 404. Returns the raw manifest (its name is authoritative after a rename).

Source code in zettelkasten/dashboard/backend/routes/reviews.py
@router.patch("/reviews/{name}/metadata")
def edit_review_metadata_route(name: str, req: EditReviewMetadataRequest) -> dict:
    """Edit a local review's scalar metadata and/or rename its slug.

    Federated (namespaced) targets are read-only → 403 before any write. Scalar
    edits (title/question/project/graph) are applied first on the current slug,
    then the rename (if any) moves the files — so the returned manifest reflects
    the final on-disk state. A bad/colliding name → 400; a missing review → 404.
    Returns the raw manifest (its ``name`` is authoritative after a rename).
    """
    local, repo = _resolve_ref(name)
    if repo is not None:
        raise HTTPException(status_code=403, detail="Federated repos are read-only.")
    try:
        if any(
            v is not None
            for v in (req.title, req.question, req.project, req.graph)
        ):
            review.update_review(
                local,
                title=req.title,
                question=req.question,
                project=req.project,
                graph=req.graph,
            )
        if req.new_name is not None and req.new_name != local:
            return review.rename_review(local, req.new_name)
        manifest = load_review(local, graphs_dir=GRAPHS_DIR)
        if not manifest:
            raise HTTPException(status_code=404, detail=f"Review '{name}' not found.")
        return manifest
    except TimeoutError:
        raise HTTPException(
            status_code=503, detail="Review write lock busy; please retry."
        )
    except FileNotFoundError:
        raise HTTPException(status_code=404, detail=f"Review '{name}' not found.")
    except ValueError as e:
        raise HTTPException(status_code=400, detail=str(e))

delete_review_route

delete_review_route(name: str) -> dict

Hard-delete a local review's durable overlay (manifest + outline sidecar).

The HTTP counterpart of the MCP review('delete') action. Federated (namespaced) targets are a read-only overlay → 403 before any write. Deletion is the one NON-REGENERABLE destructive review action, so it is also refused when deletes are disabled (ZK_DISABLE_DELETE), mirroring that barrier. Claims and papers are untouched — only the editorial overlay is removed. A missing review is reported as {"deleted": False} rather than a 404.

Source code in zettelkasten/dashboard/backend/routes/reviews.py
@router.delete("/reviews/{name}")
def delete_review_route(name: str) -> dict:
    """Hard-delete a local review's durable overlay (manifest + outline sidecar).

    The HTTP counterpart of the MCP ``review('delete')`` action. Federated
    (namespaced) targets are a read-only overlay → 403 before any write. Deletion
    is the one NON-REGENERABLE destructive review action, so it is also refused
    when deletes are disabled (``ZK_DISABLE_DELETE``), mirroring that barrier.
    Claims and papers are untouched — only the editorial overlay is removed. A
    missing review is reported as ``{"deleted": False}`` rather than a 404.
    """
    import zettelkasten.server as zk_server

    local, repo = _resolve_ref(name)
    if repo is not None:
        raise HTTPException(status_code=403, detail="Federated repos are read-only.")
    if zk_server._DELETE_DISABLED:
        raise HTTPException(
            status_code=403,
            detail="Graph deletes are disabled (ZK_DISABLE_DELETE); deleting a review removes its manifest.",
        )
    try:
        return review.delete_review(local, graphs_dir=GRAPHS_DIR)
    except TimeoutError:
        raise HTTPException(
            status_code=503, detail="Review write lock busy; please retry."
        )
    except ValueError as e:
        raise HTTPException(status_code=400, detail=str(e))

create_review_claim

create_review_claim(name: str, req: CreateClaimRequest) -> dict

Create-or-overwrite a grounded _cross claim (the producer write path).

The review name is the editorial context; the claim itself is authored into the shared _cross graph via claim_producer.create_claim (per- claim lock + atomic write + scheduled git commit). A federated (namespaced) review is read-only → 403 before any write; the producer ALSO refuses a federated _cross/member target (→ 403). A bad id / out-of-range confidence → 400.

Source code in zettelkasten/dashboard/backend/routes/reviews.py
@router.post("/reviews/{name}/claims")
def create_review_claim(name: str, req: CreateClaimRequest) -> dict:
    """Create-or-overwrite a grounded ``_cross`` claim (the producer write path).

    The review ``name`` is the editorial context; the claim itself is authored
    into the shared ``_cross`` graph via ``claim_producer.create_claim`` (per-
    claim lock + atomic write + scheduled git commit). A federated (namespaced)
    review is read-only → 403 before any write; the producer ALSO refuses a
    federated ``_cross``/member target (→ 403). A bad id / out-of-range
    confidence → 400.
    """
    if _resolve_ref(name)[1] is not None:
        raise HTTPException(status_code=403, detail="Federated repos are read-only.")
    try:
        return claim_producer.create_claim(
            _producer_get_graph,
            claim_id=req.claim_id,
            title=req.title,
            body=req.body,
            supports=req.supports,
            contradicts=req.contradicts,
            tags=req.tags,
            source=req.source,
            status=req.status,
            confidence=req.confidence,
            graphs_dir=GRAPHS_DIR,
        )
    except PermissionError as e:
        raise HTTPException(status_code=403, detail=str(e))
    except TimeoutError:
        raise HTTPException(
            status_code=503, detail="Claim write lock busy; please retry."
        )
    except ValueError as e:
        raise HTTPException(status_code=400, detail=str(e))

promote_review_claim

promote_review_claim(name: str, req: PromoteClaimRequest) -> dict

Promote a tested hypothesis into a grounded _cross claim — WRITES.

The acceptance step for the hypothesis tester's promote descriptor: delegates to claim_producer.materialize_claim (a create_claim that ALSO writes the verbatim evidence quote notes), under the gated producer write path (per-claim lock + atomic write + scheduled git commit). Unlike the read-only test, this WRITES, so a federated (namespaced) review is read-only → 403 before any write; the producer ALSO refuses a federated _cross / member target (→ 403). A bad id / out-of-range confidence / missing title → 400. (The generic /actions route does NOT dispatch materialize_claim; this is its dedicated write surface.)

Source code in zettelkasten/dashboard/backend/routes/reviews.py
@router.post("/reviews/{name}/promote-claim")
def promote_review_claim(name: str, req: PromoteClaimRequest) -> dict:
    """Promote a tested hypothesis into a grounded ``_cross`` claim — WRITES.

    The acceptance step for the hypothesis tester's ``promote`` descriptor:
    delegates to ``claim_producer.materialize_claim`` (a ``create_claim`` that
    ALSO writes the verbatim evidence ``quote`` notes), under the gated producer
    write path (per-claim lock + atomic write + scheduled git commit). Unlike the
    read-only test, this WRITES, so a federated (namespaced) review is read-only →
    403 before any write; the producer ALSO refuses a federated ``_cross`` /
    member target (→ 403). A bad id / out-of-range confidence / missing title →
    400. (The generic ``/actions`` route does NOT dispatch ``materialize_claim``;
    this is its dedicated write surface.)
    """
    if _resolve_ref(name)[1] is not None:
        raise HTTPException(status_code=403, detail="Federated repos are read-only.")
    try:
        return claim_producer.materialize_claim(
            _producer_get_graph,
            claim_id=req.claim_id,
            title=req.title,
            body=req.body,
            supports=req.supports,
            contradicts=req.contradicts,
            quotes=req.quotes,
            tags=req.tags,
            confidence=req.confidence,
            graphs_dir=GRAPHS_DIR,
        )
    except PermissionError as e:
        raise HTTPException(status_code=403, detail=str(e))
    except TimeoutError:
        raise HTTPException(
            status_code=503, detail="Claim write lock busy; please retry."
        )
    except ValueError as e:
        raise HTTPException(status_code=400, detail=str(e))

ground_review_claim

ground_review_claim(name: str, req: GroundClaimRequest) -> dict

Scrape a thin claim's own sources for verbatim supporting quotes — WRITES.

The actionable side of an evidential research gap: instead of only saying "this claim is thin", it re-reads the claim's supporting source PDFs, asks a write-free extraction agent for verbatim passages that support the claim, and MECHANICALLY VERIFIES each candidate against the source text (grounding.verify_quote) before writing it as a grounded quote note — so the agent surfaces but never fabricates evidence. Scope is taken from the review manifest's project/graph. WRITES to _cross → a federated (namespaced) review is read-only → 403 before any write. Missing review → 404 / corrupt → 422; a bad / unknown claim id → 400.

Source code in zettelkasten/dashboard/backend/routes/reviews.py
@router.post("/reviews/{name}/ground-claim")
def ground_review_claim(name: str, req: GroundClaimRequest) -> dict:
    """Scrape a thin claim's own sources for verbatim supporting quotes — WRITES.

    The actionable side of an ``evidential`` research gap: instead of only saying
    "this claim is thin", it re-reads the claim's supporting source PDFs, asks a
    write-free extraction agent for verbatim passages that support the claim, and
    MECHANICALLY VERIFIES each candidate against the source text
    (``grounding.verify_quote``) before writing it as a grounded ``quote`` note —
    so the agent surfaces but never fabricates evidence. Scope is taken from the
    review manifest's ``project``/``graph``. WRITES to ``_cross`` → a federated
    (namespaced) review is read-only → 403 before any write. Missing review → 404
    / corrupt → 422; a bad / unknown claim id → 400.
    """
    if _resolve_ref(name)[1] is not None:
        raise HTTPException(status_code=403, detail="Federated repos are read-only.")
    manifest, _repo = _load_review_or_error(name)
    graph = manifest.get("graph", "") or ""
    project = manifest.get("project", "") or ""
    try:
        return claim_producer.ground_claim(
            _producer_get_graph,
            claim_id=req.claim_id,
            project=("" if graph else project),
            graph=graph,
            # Opt into the write-free in-process extraction agent (the dashboard's
            # only ground path); tests inject a stub extract_fn instead.
            extract_fn=claim_producer._default_extract_fn,
            graphs_dir=GRAPHS_DIR,
        )
    except PermissionError as e:
        raise HTTPException(status_code=403, detail=str(e))
    except TimeoutError:
        raise HTTPException(
            status_code=503, detail="Claim write lock busy; please retry."
        )
    except ValueError as e:
        raise HTTPException(status_code=400, detail=str(e))

ground_review_paper

ground_review_paper(name: str, req: GroundPaperRequest) -> dict

Scrape a paper's own source for its missing CCC notes — WRITES.

The actionable side of the outline's per-paper [GAP:<paper-id>] marker: instead of only saying "this paper has no definition / mechanism / numbers / quote notes on file", it resolves the paper to its owned source graph, re-reads that source's extracted full text, asks a write-free extraction agent for VERBATIM passages bucketed by CCC slot, and MECHANICALLY VERIFIES each candidate against the source text (grounding.verify_quote) before writing it as a typed grounded note — so the agent surfaces but never fabricates. Scope is taken from the review manifest's project/graph. WRITES to the source graph → a federated (namespaced) review is read-only → 403 before any write. Missing review → 404 / corrupt → 422; a bad / unknown paper id → 400.

Source code in zettelkasten/dashboard/backend/routes/reviews.py
@router.post("/reviews/{name}/ground-paper")
def ground_review_paper(name: str, req: GroundPaperRequest) -> dict:
    """Scrape a paper's own source for its missing CCC notes — WRITES.

    The actionable side of the outline's per-paper ``[GAP:<paper-id>]`` marker:
    instead of only saying "this paper has no definition / mechanism / numbers /
    quote notes on file", it resolves the paper to its owned source graph, re-reads
    that source's extracted full text, asks a write-free extraction agent for
    VERBATIM passages bucketed by CCC slot, and MECHANICALLY VERIFIES each candidate
    against the source text (``grounding.verify_quote``) before writing it as a
    typed grounded note — so the agent surfaces but never fabricates. Scope is taken
    from the review manifest's ``project``/``graph``. WRITES to the source graph → a
    federated (namespaced) review is read-only → 403 before any write. Missing
    review → 404 / corrupt → 422; a bad / unknown paper id → 400.
    """
    if _resolve_ref(name)[1] is not None:
        raise HTTPException(status_code=403, detail="Federated repos are read-only.")
    manifest, _repo = _load_review_or_error(name)
    graph = manifest.get("graph", "") or ""
    project = manifest.get("project", "") or ""
    try:
        return claim_producer.ground_paper(
            _producer_get_graph,
            paper_id=req.paper_id,
            project=("" if graph else project),
            graph=graph,
            # Opt into the write-free in-process extraction agent (the dashboard's
            # only ground path); tests inject a stub extract_fn instead.
            extract_fn=claim_producer._default_paper_extract_fn,
            graphs_dir=GRAPHS_DIR,
        )
    except PermissionError as e:
        raise HTTPException(status_code=403, detail=str(e))
    except TimeoutError:
        raise HTTPException(
            status_code=503, detail="Claim write lock busy; please retry."
        )
    except ValueError as e:
        raise HTTPException(status_code=400, detail=str(e))

delete_review_claim

delete_review_claim(name: str, claim_id: str = Query(..., description='Id of the _cross claim to soft-delete')) -> dict

Soft-delete a _cross claim (status=discarded + tombstoned edges).

Reversible, git-committed, NOT the hard delete_note — delegates to claim_producer.delete_claim. A federated review is read-only → 403 before any write. An unknown claim → 404; a non-claim id / bad id → 400.

Source code in zettelkasten/dashboard/backend/routes/reviews.py
@router.delete("/reviews/{name}/claims")
def delete_review_claim(
    name: str,
    claim_id: str = Query(..., description="Id of the _cross claim to soft-delete"),
) -> dict:
    """Soft-delete a ``_cross`` claim (``status=discarded`` + tombstoned edges).

    Reversible, git-committed, NOT the hard ``delete_note`` — delegates to
    ``claim_producer.delete_claim``. A federated review is read-only → 403 before
    any write. An unknown claim → 404; a non-claim id / bad id → 400.
    """
    if _resolve_ref(name)[1] is not None:
        raise HTTPException(status_code=403, detail="Federated repos are read-only.")
    try:
        result = claim_producer.delete_claim(
            _producer_get_graph, claim_id=claim_id, graphs_dir=GRAPHS_DIR,
        )
    except PermissionError as e:
        raise HTTPException(status_code=403, detail=str(e))
    except TimeoutError:
        raise HTTPException(
            status_code=503, detail="Claim write lock busy; please retry."
        )
    except ValueError as e:
        raise HTTPException(status_code=400, detail=str(e))
    if result.get("type") == "NotFound":
        raise HTTPException(status_code=404, detail=result.get("error", "Claim not found."))
    return result

delete_review_theme

delete_review_theme(name: str, theme_id: str = Query(..., description='Id of the _cross landscape concept hub to soft-delete')) -> dict

Soft-delete a materialized theme (its _cross landscape concept hub).

The hub-typed counterpart of DELETE /reviews/{name}/claims: a real (non-proposed) review theme is a type="concept" landscape hub, which delete_claim refuses, so deletion is delegated to claim_producer.delete_theme instead (status=discarded, reversible and git-committed, dropped from every active landscape view). A federated review is read-only → 403 before any write. An unknown hub → 404; a non-concept id / bad id → 400.

Source code in zettelkasten/dashboard/backend/routes/reviews.py
@router.delete("/reviews/{name}/theme")
def delete_review_theme(
    name: str,
    theme_id: str = Query(..., description="Id of the _cross landscape concept hub to soft-delete"),
) -> dict:
    """Soft-delete a materialized theme (its ``_cross`` landscape concept hub).

    The hub-typed counterpart of ``DELETE /reviews/{name}/claims``: a real
    (non-proposed) review theme is a ``type="concept"`` landscape hub, which
    ``delete_claim`` refuses, so deletion is delegated to
    ``claim_producer.delete_theme`` instead (``status=discarded``, reversible and
    git-committed, dropped from every active landscape view). A federated review
    is read-only → 403 before any write. An unknown hub → 404; a non-concept id
    / bad id → 400.
    """
    if _resolve_ref(name)[1] is not None:
        raise HTTPException(status_code=403, detail="Federated repos are read-only.")
    try:
        result = claim_producer.delete_theme(
            _producer_get_graph, theme_id=theme_id, graphs_dir=GRAPHS_DIR,
        )
    except PermissionError as e:
        raise HTTPException(status_code=403, detail=str(e))
    except TimeoutError:
        raise HTTPException(
            status_code=503, detail="Theme write lock busy; please retry."
        )
    except ValueError as e:
        raise HTTPException(status_code=400, detail=str(e))
    if result.get("type") == "NotFound":
        raise HTTPException(status_code=404, detail=result.get("error", "Theme hub not found."))
    return result

autosave_review_overlay

autosave_review_overlay(name: str, req: UpdateReviewRequest) -> dict

Autosaving overlay-mutation endpoint: persist EVERY editorial edit.

The write the frontend fires on each overlay edit — theme accept, narrative reorder, inserted note/question/todo, retier, exclude. It delegates to review.update_review, the M3a durable substrate (per-review lock → atomic temp+rename → debounced zettelkasten-mcp git commit), so an edit is crash-safe and versioned and is NEVER silently dropped: a validation problem surfaces as 400, a missing review as 404, and any substrate failure propagates (500) rather than being swallowed. Federated (namespaced) targets are read-only → 403 before any write. Twin of PUT /reviews/{name} — the explicit autosave verb the producer UI calls.

Source code in zettelkasten/dashboard/backend/routes/reviews.py
@router.post("/reviews/{name}/overlay")
def autosave_review_overlay(name: str, req: UpdateReviewRequest) -> dict:
    """Autosaving overlay-mutation endpoint: persist EVERY editorial edit.

    The write the frontend fires on each overlay edit — theme accept, narrative
    reorder, inserted note/question/todo, retier, exclude. It delegates to
    ``review.update_review``, the M3a durable substrate (per-review lock → atomic
    temp+rename → debounced ``zettelkasten-mcp`` git commit), so an edit is
    crash-safe and versioned and is NEVER silently dropped: a validation problem
    surfaces as 400, a missing review as 404, and any substrate failure
    propagates (500) rather than being swallowed. Federated (namespaced) targets
    are read-only → 403 before any write. Twin of ``PUT /reviews/{name}`` — the
    explicit autosave verb the producer UI calls.
    """
    local, repo = _resolve_ref(name)
    if repo is not None:
        raise HTTPException(status_code=403, detail="Federated repos are read-only.")
    try:
        return review.update_review(
            local,
            outline_id=req.outline_id,
            title=req.title,
            question=req.question,
            ordering=req.ordering,
            set_tier=req.set_tier,
            exclude=req.exclude,
            unexclude=req.unexclude,
            add_scaffold=req.add_scaffold,
            remove_scaffold=req.remove_scaffold,
            rename_section=req.rename_section,
            set_section_note=req.set_section_note,
            set_placement=req.set_placement,
            favorite=req.favorite,
            unfavorite=req.unfavorite,
            reset_structure=bool(req.reset_structure),
            save_structure=bool(req.save_structure),
            restore_structure=bool(req.restore_structure),
            add_proposed_claim=req.add_proposed_claim,
            remove_proposed_claim=req.remove_proposed_claim,
            add_proposed_theme=req.add_proposed_theme,
            remove_proposed_theme=req.remove_proposed_theme,
            remove_derived_theme=req.remove_derived_theme,
            restore_derived_theme=req.restore_derived_theme,
        )
    except TimeoutError:
        raise HTTPException(
            status_code=503, detail="Review write lock busy; please retry."
        )
    except ValueError as e:
        raise HTTPException(status_code=400, detail=str(e))
    except FileNotFoundError:
        raise HTTPException(status_code=404, detail=f"Review '{name}' not found.")

test_review_claim

test_review_claim(name: str, req: TestClaimRequest) -> dict

Test a hypothesis against the review's corpus — READ-ONLY (writes nothing).

Scope is taken from the review manifest's project/graph (the SAME corpus the review projects). Delegates to claim_producer.test_claim: an ephemeral embed + deterministic evidence GATHER + a write-free stance agent → a verdict (supported | contested | refuted | untested-in-corpus) with for/against tally, the classified evidence, the top gaps, and a gated materialize promote descriptor. Allowed on a federated review (read-only). Missing review → 404 / corrupt → 422; an empty hypothesis → 400.

Source code in zettelkasten/dashboard/backend/routes/reviews.py
@router.post("/reviews/{name}/test-claim")
def test_review_claim(name: str, req: TestClaimRequest) -> dict:
    """Test a hypothesis against the review's corpus — READ-ONLY (writes nothing).

    Scope is taken from the review manifest's ``project``/``graph`` (the SAME
    corpus the review projects). Delegates to ``claim_producer.test_claim``: an
    ephemeral embed + deterministic evidence GATHER + a write-free stance agent →
    a verdict (supported | contested | refuted | untested-in-corpus) with
    for/against tally, the classified evidence, the top gaps, and a gated
    ``materialize`` promote descriptor. Allowed on a federated review (read-only).
    Missing review → 404 / corrupt → 422; an empty hypothesis → 400.
    """
    manifest, repo = _load_review_or_error(name)
    base = repo.zettel_dir if repo is not None else GRAPHS_DIR
    ns = (lambda s: federation.namespace_id(repo.id, s)) if repo is not None else (lambda s: s)
    loc = lambda d: _resolve_ref(d)[0]
    graph = manifest.get("graph", "") or ""
    project = manifest.get("project", "") or ""
    try:
        return claim_producer.test_claim(
            _get_graph,
            text=req.text,
            project=("" if graph else project),
            graph=graph,
            top_k=req.top_k if req.top_k > 0 else claim_producer.TEST_MAX_CANDIDATES,
            # Opt into the natural-language assessment (the write-free in-process
            # agent). The deterministic /actions claim-test path omits it.
            summarize_fn=claim_producer._default_summarize_fn,
            graphs_dir=base,
            namespace=ns,
            localize=loc,
        )
    except ValueError as e:
        raise HTTPException(status_code=400, detail=str(e))

suggest_review_claims

suggest_review_claims(name: str, req: SuggestClaimsRequest) -> dict

Rank EXISTING corpus claims relevant to a pitched topic — READ-ONLY.

The retrieve half of "propose a theme": embeds the topic as an ephemeral vector and ranks the review corpus's existing claims by similarity, so the caller can auto-place the matches under a freshly-pitched draft section. Scope is the review manifest's project/graph. Writes nothing and proposes no new claims — allowed on a federated review. Missing review → 404 / corrupt → 422; an empty topic → 400.

Source code in zettelkasten/dashboard/backend/routes/reviews.py
@router.post("/reviews/{name}/suggest-claims")
def suggest_review_claims(name: str, req: SuggestClaimsRequest) -> dict:
    """Rank EXISTING corpus claims relevant to a pitched topic — READ-ONLY.

    The retrieve half of "propose a theme": embeds the topic as an ephemeral
    vector and ranks the review corpus's existing claims by similarity, so the
    caller can auto-place the matches under a freshly-pitched draft section. Scope
    is the review manifest's ``project``/``graph``. Writes nothing and proposes no
    new claims — allowed on a federated review. Missing review → 404 / corrupt →
    422; an empty topic → 400.
    """
    manifest, repo = _load_review_or_error(name)
    base = repo.zettel_dir if repo is not None else GRAPHS_DIR
    ns = (lambda s: federation.namespace_id(repo.id, s)) if repo is not None else (lambda s: s)
    loc = lambda d: _resolve_ref(d)[0]
    graph = manifest.get("graph", "") or ""
    project = manifest.get("project", "") or ""
    try:
        return claim_producer.suggest_claims_for_topic(
            _get_graph,
            text=req.text,
            project=("" if graph else project),
            graph=graph,
            top_k=req.top_k if req.top_k > 0 else claim_producer.SUGGEST_MAX_CLAIMS,
            graphs_dir=base,
            namespace=ns,
            localize=loc,
        )
    except ValueError as e:
        raise HTTPException(status_code=400, detail=str(e))

propose_review_placements

propose_review_placements(name: str, outline: str = '', granularity: int = Query(50, ge=10, le=90, description='Topic granularity (10=fewer/broader, 90=more/narrower)'), whole_pool: bool = Query(False, description='Cluster EVERY in-scope claim (create mode) instead of only the unplaced leftovers relative to the structure (edit/rebuild mode)')) -> dict

Propose a home for each unplaced claim — READ-ONLY.

The placement companion to "propose a theme": reconciles the review, then for each unplaced claim proposes EITHER an existing theme (the claim sits close to that theme's claim centroid) OR membership in a clustered NEW theme (leftovers that cohere among themselves). Deterministic and write-free — proposes nothing the author cannot reject and creates no claim or theme; the caller lands an approved placement via set_placement and an approved new theme via add_proposed_theme. Allowed on a federated review (no writes). Missing review → 404 / corrupt → 422.

granularity (10..90, mirroring the Concepts-graph slider) steers the leftover-cluster cosine cut via :func:claim_producer.cluster_threshold_for_granularity — low = fewer/broader topics, high = more/narrower; the midpoint (50) is the legacy default.

whole_pool switches the clustering POOL: with it set (the wizard's CREATE mode) every in-scope claim is clustered as a fresh topic, ignoring the default structure's existing placements; without it (the wizard's REBUILD mode and the Unplaced-card button) only the structure's UNPLACED leftovers are clustered.

Source code in zettelkasten/dashboard/backend/routes/reviews.py
@router.post("/reviews/{name}/propose-placements")
def propose_review_placements(
    name: str,
    outline: str = "",
    granularity: int = Query(
        50, ge=10, le=90,
        description="Topic granularity (10=fewer/broader, 90=more/narrower)",
    ),
    whole_pool: bool = Query(
        False,
        description=(
            "Cluster EVERY in-scope claim (create mode) instead of only the "
            "unplaced leftovers relative to the structure (edit/rebuild mode)"
        ),
    ),
) -> dict:
    """Propose a home for each unplaced claim — READ-ONLY.

    The placement companion to "propose a theme": reconciles the review, then for
    each unplaced claim proposes EITHER an existing theme (the claim sits close to
    that theme's claim centroid) OR membership in a clustered NEW theme (leftovers
    that cohere among themselves). Deterministic and write-free — proposes nothing
    the author cannot reject and creates no claim or theme; the caller lands an
    approved placement via ``set_placement`` and an approved new theme via
    ``add_proposed_theme``. Allowed on a federated review (no writes). Missing
    review → 404 / corrupt → 422.

    ``granularity`` (10..90, mirroring the Concepts-graph slider) steers the
    leftover-cluster cosine cut via
    :func:`claim_producer.cluster_threshold_for_granularity` — low = fewer/broader
    topics, high = more/narrower; the midpoint (50) is the legacy default.

    ``whole_pool`` switches the clustering POOL: with it set (the wizard's CREATE
    mode) every in-scope claim is clustered as a fresh topic, ignoring the default
    structure's existing placements; without it (the wizard's REBUILD mode and the
    Unplaced-card button) only the structure's UNPLACED leftovers are clustered.
    """
    local, _r = _resolve_ref(name)
    manifest, repo = _load_review_or_error(name)
    base = repo.zettel_dir if repo is not None else GRAPHS_DIR
    ns = (lambda s: federation.namespace_id(repo.id, s)) if repo is not None else (lambda s: s)
    loc = lambda d: _resolve_ref(d)[0]
    # REBUILD mode homes unplaced claims into EXISTING theme centroids, so keep
    # derived (cluster / cold-start) tiers PLACED in ``themes`` rather than lifting
    # them to ``unplaced`` (which would re-cluster already-derived groupings from
    # scratch). The opt-in split stays a builder/board + gather concern.
    view = review.reconcile(
        _get_graph,
        name=local,
        manifest=manifest,
        outline_id=outline,
        graphs_dir=base,
        namespace=ns,
        localize=loc,
        split_derived=False,
    )
    themes = view.get("themes") or []
    unplaced = view.get("unplaced") or []
    if whole_pool:
        # CREATE mode: discover topics across the FULL in-scope claim pool, not
        # just what is unplaced relative to the (default) structure. Feeding every
        # claim in as a leftover with NO existing-theme centroids (themes=[]) turns
        # the proposer into a pure clusterer over the whole corpus. Dedup by uid so
        # a claim that is both themed and (defensively) unplaced is clustered once.
        seen: set[str] = set()
        pool: list[dict[str, Any]] = []
        for c in (*(cl for t in themes for cl in (t.get("claims") or [])), *unplaced):
            uid = str(c.get("uid") or "")
            if uid and uid in seen:
                continue
            if uid:
                seen.add(uid)
            pool.append(c)
        themes, unplaced = [], pool
    return claim_producer.propose_placements(
        _get_graph,
        themes=themes,
        unplaced=unplaced,
        question=view.get("question", "") or "",
        scope=view.get("scope") or {},
        cluster_threshold=claim_producer.cluster_threshold_for_granularity(granularity),
    )

review_structural_themes

review_structural_themes(name: str, req: StructuralThemesRequest, outline: str = '') -> dict

Partition the review's claim pool into STRUCTURAL themes — READ-ONLY.

The structural counterpart to /propose-placements: instead of discovering themes by claim-vector similarity, it reconciles the review (the SAME projection path propose-placements builds its claim pool from) and partitions that pool by a deterministic, PRE-BUILT axis — the supporting note's source graph (source), its frontmatter note_type / tags, or the dimension of a named spine organization it belongs to. The axis vocabulary and membership mirror the matrix builder's row axes and the outline's spine section partition 1:1, so NO semantic clustering happens here. Returns {scope, axis, themes:[{key, title, claims, count}]} (themes sorted by count desc). Allowed on a federated review (no writes). Missing review → 404 / corrupt → 422; an unknown axis or a missing/unresolvable spine → 400.

Source code in zettelkasten/dashboard/backend/routes/reviews.py
@router.post("/reviews/{name}/structural-themes")
def review_structural_themes(
    name: str, req: StructuralThemesRequest, outline: str = ""
) -> dict:
    """Partition the review's claim pool into STRUCTURAL themes — READ-ONLY.

    The structural counterpart to ``/propose-placements``: instead of discovering
    themes by claim-vector similarity, it reconciles the review (the SAME
    projection path propose-placements builds its claim pool from) and partitions
    that pool by a deterministic, PRE-BUILT axis — the supporting note's source
    graph (``source``), its frontmatter ``note_type`` / ``tags``, or the dimension
    of a named ``spine`` organization it belongs to. The axis vocabulary and
    membership mirror the matrix builder's row axes and the outline's spine section
    partition 1:1, so NO semantic clustering happens here. Returns
    ``{scope, axis, themes:[{key, title, claims, count}]}`` (themes sorted by count
    desc). Allowed on a federated review (no writes). Missing review → 404 /
    corrupt → 422; an unknown axis or a missing/unresolvable spine → 400.
    """
    local, _r = _resolve_ref(name)
    manifest, repo = _load_review_or_error(name)
    base = repo.zettel_dir if repo is not None else GRAPHS_DIR
    ns = (lambda s: federation.namespace_id(repo.id, s)) if repo is not None else (lambda s: s)
    loc = lambda d: _resolve_ref(d)[0]
    graph = manifest.get("graph", "") or ""
    project = manifest.get("project", "") or ""
    view = review.reconcile(
        _get_graph,
        name=local,
        manifest=manifest,
        outline_id=outline,
        graphs_dir=base,
        namespace=ns,
        localize=loc,
    )
    try:
        return claim_producer.partition_structural_themes(
            _get_graph,
            kind=req.kind,
            themes=view.get("themes") or [],
            unplaced=view.get("unplaced") or [],
            spine=req.spine,
            spine_mode=req.spine_mode,
            include_unplaced=req.include_unplaced,
            project=("" if graph else project),
            graph=graph,
            scope=view.get("scope") or {},
            graphs_dir=base,
            localize=loc,
        )
    except ValueError as e:
        raise HTTPException(status_code=400, detail=str(e))

propose_review_inbound_claims

propose_review_inbound_claims(name: str, req: InboundClaimsRequest) -> dict

Propose claims to pull INTO one theme — READ-ONLY.

The per-theme inverse of "propose placements": given a target theme, surfaces the Unplaced claims that fit its claim centroid and (when scope is "all") EVERY claim currently placed in another theme that fits this theme above the floor — grouped by where each claim currently sits, each with its current-theme fit so the author can judge the move. Deterministic and write-free — proposes nothing the author cannot reject and moves nothing; the caller lands an approved move via set_placement. Allowed on a federated review (no writes). Missing review → 404 / corrupt → 422.

Source code in zettelkasten/dashboard/backend/routes/reviews.py
@router.post("/reviews/{name}/inbound-claims")
def propose_review_inbound_claims(name: str, req: InboundClaimsRequest) -> dict:
    """Propose claims to pull INTO one theme — READ-ONLY.

    The per-theme inverse of "propose placements": given a target theme, surfaces
    the Unplaced claims that fit its claim centroid and (when ``scope`` is "all")
    EVERY claim currently placed in another theme that fits this theme above the
    floor — grouped by where each claim currently sits, each with its current-theme
    fit so the author can judge the move. Deterministic and write-free — proposes
    nothing the author cannot reject and moves nothing; the caller lands an approved
    move via ``set_placement``. Allowed on a federated review (no writes). Missing
    review → 404 / corrupt → 422.
    """
    manifest, repo = _load_review_or_error(name)
    base = repo.zettel_dir if repo is not None else GRAPHS_DIR
    ns = (lambda s: federation.namespace_id(repo.id, s)) if repo is not None else (lambda s: s)
    loc = lambda d: _resolve_ref(d)[0]
    view = review.reconcile(
        _get_graph,
        manifest=manifest,
        graphs_dir=base,
        namespace=ns,
        localize=loc,
    )
    return claim_producer.propose_inbound_claims(
        _get_graph,
        target_theme_id=req.theme_id,
        themes=view.get("themes") or [],
        unplaced=view.get("unplaced") or [],
        include_placed=(req.scope != "unplaced"),
        require_better_fit=(req.scope == "unplaced"),
        scope=view.get("scope") or {},
    )

mine_review_claims

mine_review_claims(name: str, req: MineClaimsRequest) -> dict

Mine NEW grounded claim candidates for a pitched topic — full-text, READ-ONLY.

The mine half of "propose a theme": gathers the corpus notes most similar to the topic, RE-READS their full note bodies AND each source's extracted PDF fulltext, and asks a write-free phrasing agent to paraphrase grounded claim candidates. Scope is the review manifest's project/graph. Writes nothing and creates no graph claim — the caller lands accepted candidates as overlay draft claims. Allowed on a federated review. Missing review → 404 / corrupt → 422; an empty topic → 400.

Source code in zettelkasten/dashboard/backend/routes/reviews.py
@router.post("/reviews/{name}/mine-claims")
def mine_review_claims(name: str, req: MineClaimsRequest) -> dict:
    """Mine NEW grounded claim candidates for a pitched topic — full-text, READ-ONLY.

    The mine half of "propose a theme": gathers the corpus notes most similar to
    the topic, RE-READS their full note bodies AND each source's extracted PDF
    fulltext, and asks a write-free phrasing agent to paraphrase grounded claim
    candidates. Scope is the review manifest's ``project``/``graph``. Writes
    nothing and creates no graph claim — the caller lands accepted candidates as
    overlay draft claims. Allowed on a federated review. Missing review → 404 /
    corrupt → 422; an empty topic → 400.
    """
    manifest, repo = _load_review_or_error(name)
    base = repo.zettel_dir if repo is not None else GRAPHS_DIR
    ns = (lambda s: federation.namespace_id(repo.id, s)) if repo is not None else (lambda s: s)
    loc = lambda d: _resolve_ref(d)[0]
    graph = manifest.get("graph", "") or ""
    project = manifest.get("project", "") or ""
    try:
        return claim_producer.mine_claims_for_topic(
            _get_graph,
            text=req.text,
            project=("" if graph else project),
            graph=graph,
            top_k=req.top_k if req.top_k > 0 else claim_producer.MINE_MAX_MEMBERS,
            graphs_dir=base,
            namespace=ns,
            localize=loc,
        )
    except ValueError as e:
        raise HTTPException(status_code=400, detail=str(e))

propose_review_mine_sources

propose_review_mine_sources(name: str, req: MineSourcesRequest) -> dict

Propose candidate SOURCE papers to mine for one theme — READ-ONLY.

The first half of the interactive "Mine from sources" flow: returns the source graphs already backing the target theme (in_theme) plus other in-scope source papers most similar to the theme (proposed), each ranked for the author to pick which to extract atomic claims from. Scope stays within the review manifest's project/graph — the "proposed" search never leaves the current review scope. Writes nothing. Allowed on a federated review. Missing review → 404 / corrupt → 422.

Source code in zettelkasten/dashboard/backend/routes/reviews.py
@router.post("/reviews/{name}/mine-sources")
def propose_review_mine_sources(name: str, req: MineSourcesRequest) -> dict:
    """Propose candidate SOURCE papers to mine for one theme — READ-ONLY.

    The first half of the interactive "Mine from sources" flow: returns the source
    graphs already backing the target theme (``in_theme``) plus other in-scope
    source papers most similar to the theme (``proposed``), each ranked for the
    author to pick which to extract atomic claims from. Scope stays within the
    review manifest's ``project``/``graph`` — the "proposed" search never leaves
    the current review scope. Writes nothing. Allowed on a federated review.
    Missing review → 404 / corrupt → 422.
    """
    manifest, repo = _load_review_or_error(name)
    base = repo.zettel_dir if repo is not None else GRAPHS_DIR
    ns = (lambda s: federation.namespace_id(repo.id, s)) if repo is not None else (lambda s: s)
    loc = lambda d: _resolve_ref(d)[0]
    graph = manifest.get("graph", "") or ""
    project = manifest.get("project", "") or ""
    view = review.reconcile(
        _get_graph,
        manifest=manifest,
        graphs_dir=base,
        namespace=ns,
        localize=loc,
    )
    return claim_producer.propose_mine_sources(
        _get_graph,
        target_theme_id=req.theme_id,
        themes=view.get("themes") or [],
        project=("" if graph else project),
        graph=graph,
        graphs_dir=base,
        namespace=ns,
        localize=loc,
        scope=view.get("scope") or {},
    )

mine_review_atomic_claims

mine_review_atomic_claims(name: str, req: MineAtomicRequest) -> dict

Extract MULTIPLE atomic, grounded claims from selected sources — READ-ONLY.

The second half of the interactive "Mine from sources" flow: for each selected source graph, re-reads its extracted PDF fulltext and asks a write-free agent for atomic, theme-focused claim sentences, each carrying a VERBATIM quote that is mechanically verified against the source — ungroundable claims are dropped server-side. Scope is the review manifest's project/graph. Writes nothing and creates no graph claim — the caller lands surviving claims as overlay draft claims. Allowed on a federated review. Missing review → 404 / corrupt → 422.

Source code in zettelkasten/dashboard/backend/routes/reviews.py
@router.post("/reviews/{name}/mine-atomic")
def mine_review_atomic_claims(name: str, req: MineAtomicRequest) -> dict:
    """Extract MULTIPLE atomic, grounded claims from selected sources — READ-ONLY.

    The second half of the interactive "Mine from sources" flow: for each selected
    source graph, re-reads its extracted PDF fulltext and asks a write-free agent
    for atomic, theme-focused claim sentences, each carrying a VERBATIM quote that
    is mechanically verified against the source — ungroundable claims are dropped
    server-side. Scope is the review manifest's ``project``/``graph``. Writes
    nothing and creates no graph claim — the caller lands surviving claims as
    overlay draft claims. Allowed on a federated review. Missing review → 404 /
    corrupt → 422.
    """
    manifest, repo = _load_review_or_error(name)
    base = repo.zettel_dir if repo is not None else GRAPHS_DIR
    ns = (lambda s: federation.namespace_id(repo.id, s)) if repo is not None else (lambda s: s)
    loc = lambda d: _resolve_ref(d)[0]
    graph = manifest.get("graph", "") or ""
    project = manifest.get("project", "") or ""
    view = review.reconcile(
        _get_graph,
        manifest=manifest,
        graphs_dir=base,
        namespace=ns,
        localize=loc,
    )
    return claim_producer.mine_atomic_claims(
        _get_graph,
        target_theme_id=req.theme_id,
        themes=view.get("themes") or [],
        graphs=req.graphs or [],
        project=("" if graph else project),
        graph=graph,
        graphs_dir=base,
        namespace=ns,
        localize=loc,
        scope=view.get("scope") or {},
    )

promote_review_theme

promote_review_theme(name: str, req: PromoteThemeRequest, outline: str = '') -> dict

Promote a draft (proposed) section into a REAL landscape hub — WRITES.

Turns an overlay-only pitched section into a first-class _cross concept hub: its hub members are the SOURCE PAPERS behind the section's placed claims (each real claim's supporting sources + each mined draft's grounding refs), every mined DRAFT claim is materialized into a real grounded _cross claim, and the overlay is reconciled so the now-real hub inherits the draft's slot, rename, placements, and editorial state. outline selects WHICH structure's overlay the draft section lives in (empty / "outline" → the default structure; an additional structure reads its sidecar overlay and the promote is persisted back to it). Refuses (400) a section with no claim that resolves to a source paper. Writes to _cross → 403 on a federated / read-only review. Missing review → 404 / corrupt → 422; unknown section → 404.

Source code in zettelkasten/dashboard/backend/routes/reviews.py
@router.post("/reviews/{name}/promote-theme")
def promote_review_theme(name: str, req: PromoteThemeRequest, outline: str = "") -> dict:
    """Promote a draft (proposed) section into a REAL landscape hub — WRITES.

    Turns an overlay-only pitched section into a first-class ``_cross`` concept
    hub: its hub members are the SOURCE PAPERS behind the section's placed claims
    (each real claim's supporting sources + each mined draft's grounding refs),
    every mined DRAFT claim is materialized into a real grounded ``_cross`` claim,
    and the overlay is reconciled so the now-real hub inherits the draft's slot,
    rename, placements, and editorial state. ``outline`` selects WHICH structure's
    overlay the draft section lives in (empty / "outline" → the default structure;
    an additional structure reads its sidecar overlay and the promote is persisted
    back to it). Refuses (400) a section with no claim that resolves to a source
    paper. Writes to ``_cross`` → 403 on a federated / read-only review. Missing
    review → 404 / corrupt → 422; unknown section → 404.
    """
    manifest, repo = _load_review_or_error(name)
    base = repo.zettel_dir if repo is not None else GRAPHS_DIR
    ns = (lambda s: federation.namespace_id(repo.id, s)) if repo is not None else (lambda s: s)
    loc = lambda d: _resolve_ref(d)[0]
    graph = manifest.get("graph", "") or ""
    project = manifest.get("project", "") or ""
    # The draft section + its placements/renames live in the SELECTED structure's
    # overlay (the default structure is the manifest overlay; an additional one is
    # the review-wide half merged with its sidecar structure).
    oid = outline or outlines.DEFAULT_OUTLINE_ID
    overlay = outlines.merged_overlay(name, oid, graphs_dir=base, manifest=manifest) or {}

    tid = req.theme_id.strip()
    proposed = next(
        (pt for pt in (overlay.get("proposed_themes") or [])
         if isinstance(pt, dict) and str(pt.get("id")) == tid),
        None,
    )
    if proposed is None:
        raise HTTPException(status_code=404, detail=f"Proposed section '{tid}' not found.")

    # Claims under a draft section live purely in the overlay placements (a draft
    # has no projection-derived members), so placements is the authoritative list.
    placed = [uid for uid, t in (overlay.get("placements") or {}).items() if str(t) == tid]
    proposed_claims = {
        str(pc.get("id")): pc for pc in (overlay.get("proposed_claims") or [])
        if isinstance(pc, dict)
    }

    non_source = {claim_producer.CROSS_GRAPH, "_citations"}
    index = claims.build_claim_index(
        _get_graph, project=("" if graph else project), graph=graph,
        graphs_dir=base, namespace=ns, localize=loc,
    )

    hub_members: list[str] = []
    seen_members: set[str] = set()

    def _add_member(g: str) -> None:
        if g and g not in non_source and g not in seen_members:
            seen_members.add(g)
            hub_members.append(g)

    draft_under_theme: list[dict] = []
    for uid in placed:
        if "::" in uid:  # a real claim uid: graph::id
            g, _, cid = uid.partition("::")
            resolved = index.resolved.get((g, cid))
            if resolved is not None:
                for src in resolved.support_sources:
                    _add_member(src)
        elif uid in proposed_claims:  # a mined draft claim
            pc = proposed_claims[uid]
            draft_under_theme.append(pc)
            for ref in (pc.get("supports") or []):
                if isinstance(ref, dict):
                    _add_member(str(ref.get("graph") or ""))

    if not hub_members:
        raise HTTPException(
            status_code=400,
            detail=("This section has no claim that resolves to a source paper, so "
                    "there are no members to build a landscape hub from. Add or mine "
                    "claims grounded in your sources first."),
        )

    label = (overlay.get("section_renames") or {}).get(tid) or proposed.get("title") or tid

    try:
        hub = claim_producer.materialize_theme(
            _producer_get_graph, title=label, members=hub_members,
            body=proposed.get("body") or None, graphs_dir=base,
        )
        hub_id = hub["id"]
        # Materialize each mined draft claim into a real grounded _cross claim.
        claim_remap: dict[str, str] = {}
        for pc in draft_under_theme:
            res = claim_producer.create_claim(
                _producer_get_graph,
                title=pc.get("title", ""),
                body=pc.get("body") or None,
                supports=pc.get("supports") or None,
                contradicts=pc.get("contradicts") or None,
                graphs_dir=base,
            )
            claim_remap[str(pc.get("id"))] = f"{res['graph']}::{res['id']}"
    except PermissionError as e:
        raise HTTPException(status_code=403, detail=str(e))
    except ValueError as e:
        raise HTTPException(status_code=400, detail=str(e))

    review.update_review(
        name,
        promote_proposed_theme={"theme_id": tid, "hub_id": hub_id, "claim_remap": claim_remap},
        outline_id=outline,
        graphs_dir=base,
    )
    return {
        "hub_id": hub_id,
        "title": label,
        "members": hub_members,
        "claims_materialized": len(claim_remap),
        "claim_remap": claim_remap,
    }

undo_review_promote

undo_review_promote(name: str, outline: str = '') -> dict

Reverse the most recent promote — WRITES.

The inverse of promote-theme: soft-discards (status=discarded, reversible, git-committed) the _cross landscape hub and every claim that promote materialized, then restores the overlay snapshot stashed at promote time (re-adding the draft section + draft claims and their editorial state). outline selects WHICH structure's overlay holds the undo snapshot (empty / "outline" → the default structure). Single-level — only the MOST RECENT promote is undoable, and the restore discards overlay edits made after that promote. Writes to _cross → 403 on a federated / read-only review. Missing review → 404 / corrupt → 422; nothing to undo → 404.

Source code in zettelkasten/dashboard/backend/routes/reviews.py
@router.post("/reviews/{name}/undo-promote")
def undo_review_promote(name: str, outline: str = "") -> dict:
    """Reverse the most recent promote — WRITES.

    The inverse of ``promote-theme``: soft-discards (``status=discarded``,
    reversible, git-committed) the ``_cross`` landscape hub and every claim that
    promote materialized, then restores the overlay snapshot stashed at promote
    time (re-adding the draft section + draft claims and their editorial state).
    ``outline`` selects WHICH structure's overlay holds the undo snapshot (empty /
    "outline" → the default structure). Single-level — only the MOST RECENT
    promote is undoable, and the restore discards overlay edits made after that
    promote. Writes to ``_cross`` → 403 on a federated / read-only review. Missing
    review → 404 / corrupt → 422; nothing to undo → 404.
    """
    manifest, repo = _load_review_or_error(name)
    base = repo.zettel_dir if repo is not None else GRAPHS_DIR
    oid = outline or outlines.DEFAULT_OUTLINE_ID
    overlay = outlines.merged_overlay(name, oid, graphs_dir=base, manifest=manifest) or {}

    pu = overlay.get("promote_undo")
    if not isinstance(pu, dict) or not pu.get("hub_id"):
        raise HTTPException(status_code=404, detail="No promote to undo.")
    hub_id = str(pu.get("hub_id"))
    claim_remap = pu.get("claim_remap") or {}

    try:
        # Discard the materialized claims first, then the hub. delete_claim returns
        # a NotFound dict (no raise) if a claim was already removed — fine to skip.
        for new_uid in claim_remap.values():
            cid = str(new_uid).split("::", 1)[-1]
            claim_producer.delete_claim(_producer_get_graph, claim_id=cid, graphs_dir=base)
        claim_producer.delete_theme(_producer_get_graph, theme_id=hub_id, graphs_dir=base)
    except PermissionError as e:
        raise HTTPException(status_code=403, detail=str(e))
    except ValueError as e:
        raise HTTPException(status_code=400, detail=str(e))

    review.update_review(name, undo_promote=True, outline_id=outline, graphs_dir=base)
    return {
        "hub_id": hub_id,
        "restored_theme_id": str(pu.get("theme_id") or ""),
        "claims_discarded": len(claim_remap),
    }

detach_review_spine

detach_review_spine(name: str, req: DetachSpineRequest) -> dict

Detach a spine-organized outline into an editable theme outline — WRITES.

Snapshots the outline's live SPINE partition into its overlay (proposed themes + single-home placements, resolved via the SAME source the export uses so a post-detach re-gather matches the pre-detach board), then clears the outline's spine ref so the (previously overridden) board overlay becomes the authoritative structure. The overlay write happens FIRST and the spine clear LAST, so a mid-detach failure stays recoverable (the dormant themes sit under the still-active spine; a retry finishes the detach). Federated (namespaced) targets are read-only → 403 before any write. A missing review / outline → 404; an outline with no spine, or a spine that does not resolve → 400 (nothing to detach). Returns the reconciled review projection over the now-detached outline (mirroring GET /reviews/{name}).

Source code in zettelkasten/dashboard/backend/routes/reviews.py
@router.post("/reviews/{name}/detach-spine")
def detach_review_spine(name: str, req: DetachSpineRequest) -> dict:
    """Detach a spine-organized outline into an editable theme outline — WRITES.

    Snapshots the outline's live SPINE partition into its overlay (proposed
    themes + single-home ``placements``, resolved via the SAME source the export
    uses so a post-detach re-gather matches the pre-detach board), then clears the
    outline's ``spine`` ref so the (previously overridden) board overlay becomes
    the authoritative structure. The overlay write happens FIRST and the spine
    clear LAST, so a mid-detach failure stays recoverable (the dormant themes sit
    under the still-active spine; a retry finishes the detach). Federated
    (namespaced) targets are read-only → 403 before any write. A missing review /
    outline → 404; an outline with no spine, or a spine that does not resolve →
    400 (nothing to detach). Returns the reconciled review projection over the
    now-detached outline (mirroring ``GET /reviews/{name}``).
    """
    local, repo = _resolve_ref(name)
    if repo is not None:
        raise HTTPException(status_code=403, detail="Federated repos are read-only.")
    manifest, _repo = _load_review_or_error(name)
    graph = manifest.get("graph", "") or ""
    project = manifest.get("project", "") or ""
    ns = lambda s: s
    loc = lambda d: _resolve_ref(d)[0]
    try:
        review.detach_spine(
            _get_graph,
            local,
            outline_id=req.outlineId,
            project=("" if graph else project),
            graph=graph,
            graphs_dir=GRAPHS_DIR,
            namespace=ns,
            localize=loc,
        )
    except TimeoutError:
        raise HTTPException(
            status_code=503, detail="Review write lock busy; please retry."
        )
    except FileNotFoundError:
        raise HTTPException(status_code=404, detail=f"Review '{name}' not found.")
    except ValueError as e:
        raise HTTPException(status_code=400, detail=str(e))

    # Return the reconciled projection over the now-detached (spine-cleared)
    # outline, mirroring the read view the frontend re-renders the board from.
    return review.reconcile(
        _get_graph,
        name=local,
        manifest=load_review(local, graphs_dir=GRAPHS_DIR),
        outline_id=req.outlineId,
        graphs_dir=GRAPHS_DIR,
        namespace=ns,
        localize=loc,
    )

review_attach_spine_members

review_attach_spine_members(name: str, req: AttachSpineMembersRequest) -> dict

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

Powers the Structure builder's spine-axis build: claims the author places under a spine dimension theme are written as spine-member edges on the spine graph so they surface in that dimension's section on the (read-only, spine-organized) board — editing the spine IS how you edit such a board. Idempotent + additive (never removes membership); the base notes are untouched. Federated (namespaced) targets are read-only → 403 before any write. A missing review → 404; an empty/unresolvable spine → 400. Returns {spine, attached, skipped}.

Source code in zettelkasten/dashboard/backend/routes/reviews.py
@router.post("/reviews/{name}/attach-spine-members")
def review_attach_spine_members(name: str, req: AttachSpineMembersRequest) -> dict:
    """Attach author-placed claims to a spine's dimension nodes — WRITES.

    Powers the Structure builder's spine-axis build: claims the author places
    under a spine dimension theme are written as ``spine-member`` edges on the
    spine graph so they surface in that dimension's section on the (read-only,
    spine-organized) board — editing the spine IS how you edit such a board.
    Idempotent + additive (never removes membership); the base notes are untouched.
    Federated (namespaced) targets are read-only → 403 before any write. A missing
    review → 404; an empty/unresolvable spine → 400. Returns
    ``{spine, attached, skipped}``.
    """
    local, repo = _resolve_ref(name)
    if repo is not None:
        raise HTTPException(status_code=403, detail="Federated repos are read-only.")
    manifest, _repo = _load_review_or_error(name)
    graph = manifest.get("graph", "") or ""
    project = manifest.get("project", "") or ""
    ns = lambda s: s
    loc = lambda d: _resolve_ref(d)[0]
    try:
        return review.attach_spine_members(
            _get_graph,
            local,
            spine=req.spine,
            members=[g.model_dump() for g in req.members],
            outline_id=req.outlineId,
            project=("" if graph else project),
            graph=graph,
            graphs_dir=GRAPHS_DIR,
            namespace=ns,
            localize=loc,
        )
    except TimeoutError:
        raise HTTPException(status_code=503, detail="Spine write lock busy; please retry.")
    except FileNotFoundError:
        raise HTTPException(status_code=404, detail=f"Review '{name}' not found.")
    except ValueError as e:
        raise HTTPException(status_code=400, detail=str(e))

review_gaps

review_gaps(name: str, limit: int = Query(25, ge=0, description='Max ranked gaps to return; 0 = none'), gap_types: str = Query('', description='Comma-separated gap-type filter; empty = all')) -> dict

Ranked, typed research-agenda gaps over the review's corpus (READ-ONLY).

Delegates to claims.analyze_gaps (the successor to find_gaps) over the review manifest's project/graph scope: each gap names WHY a region is incomplete (type evidential | dialectical | structural | coverage | comprehensiveness | temporal), scores a bounded severity, and wires the producer-loop action that would close it. Allowed on a federated review.

Six OPT-IN negative-space "opportunity" families — asymmetry | bridge | crux | orphaned_question | void | transfer — are emitted only when named in the gap_types filter (they scan MISSING edges + unmatched nodes across the graph and the memory↔zettelkasten boundary). The analyzer output is returned intact, so any new anchor fields those families add (e.g. a bridge's target/similarity) flow through to the frontend unchanged. Missing review → 404 / corrupt → 422.

Source code in zettelkasten/dashboard/backend/routes/reviews.py
@router.get("/reviews/{name}/gaps")
def review_gaps(
    name: str,
    limit: int = Query(25, ge=0, description="Max ranked gaps to return; 0 = none"),
    gap_types: str = Query("", description="Comma-separated gap-type filter; empty = all"),
) -> dict:
    """Ranked, typed research-agenda gaps over the review's corpus (READ-ONLY).

    Delegates to ``claims.analyze_gaps`` (the successor to ``find_gaps``) over the
    review manifest's ``project``/``graph`` scope: each gap names WHY a region is
    incomplete (type evidential | dialectical | structural | coverage |
    comprehensiveness | temporal), scores a bounded severity, and wires the
    producer-loop action that would close it. Allowed on a federated review.

    Six OPT-IN negative-space "opportunity" families — ``asymmetry`` | ``bridge``
    | ``crux`` | ``orphaned_question`` | ``void`` | ``transfer`` — are emitted only
    when named in the ``gap_types`` filter (they scan MISSING edges + unmatched
    nodes across the graph and the memory↔zettelkasten boundary). The analyzer
    output is returned intact, so any new anchor fields those families add (e.g. a
    bridge's ``target``/``similarity``) flow through to the frontend unchanged.
    Missing review → 404 / corrupt → 422.
    """
    manifest, repo = _load_review_or_error(name)
    base = repo.zettel_dir if repo is not None else GRAPHS_DIR
    ns = (lambda s: federation.namespace_id(repo.id, s)) if repo is not None else (lambda s: s)
    loc = lambda d: _resolve_ref(d)[0]
    graph = manifest.get("graph", "") or ""
    project = manifest.get("project", "") or ""
    wanted = [t.strip() for t in gap_types.split(",") if t.strip()] or None
    # Per-type budgeting for the negative-space "opportunity" families auto-enables
    # inside ``analyze_gaps`` for whichever requested families are negative-space, so
    # an unbounded family (``asymmetry`` is O(#claims)) can never bury a rare one (a
    # lone ``crux``). The historical claim-anchored fetch (no ``gap_types``) keeps the
    # single global slice, so its default behavior is unchanged.
    return claims.analyze_gaps(
        _get_graph,
        project=("" if graph else project),
        graph=graph,
        limit=limit,
        gap_types=wanted,
        graphs_dir=base,
        namespace=ns,
        localize=loc,
    )

review_missing_papers

review_missing_papers(name: str, limit: int = Query(25, ge=0, description='Max ranked acquisition targets; 0 = none')) -> dict

Ranked cited-but-unowned ACQUISITION targets over the review's corpus (READ-ONLY).

The acquisition companion to /reviews/{name}/gaps: delegates to claims.rank_missing_papers over the review manifest's project/graph scope. Each target is a cited-but-unowned work scored on claim-unlock + corpus influence + external citation mass, wired to the promote_citation action that acquires it. Allowed on a federated review. Missing review → 404 / corrupt → 422.

Source code in zettelkasten/dashboard/backend/routes/reviews.py
@router.get("/reviews/{name}/missing-papers")
def review_missing_papers(
    name: str,
    limit: int = Query(25, ge=0, description="Max ranked acquisition targets; 0 = none"),
) -> dict:
    """Ranked cited-but-unowned ACQUISITION targets over the review's corpus (READ-ONLY).

    The acquisition companion to ``/reviews/{name}/gaps``: delegates to
    ``claims.rank_missing_papers`` over the review manifest's ``project``/``graph``
    scope. Each target is a cited-but-unowned work scored on claim-unlock +
    corpus influence + external citation mass, wired to the ``promote_citation``
    action that acquires it. Allowed on a federated review. Missing review → 404
    / corrupt → 422.
    """
    manifest, repo = _load_review_or_error(name)
    base = repo.zettel_dir if repo is not None else GRAPHS_DIR
    ns = (lambda s: federation.namespace_id(repo.id, s)) if repo is not None else (lambda s: s)
    loc = lambda d: _resolve_ref(d)[0]
    graph = manifest.get("graph", "") or ""
    project = manifest.get("project", "") or ""
    return claims.rank_missing_papers(
        _get_graph,
        project=("" if graph else project),
        graph=graph,
        limit=limit,
        graphs_dir=base,
        namespace=ns,
        localize=loc,
    )

execute_review_action

execute_review_action(name: str, req: ExecuteActionRequest) -> dict

Execute a gap/missing-paper ACTION affordance against the corpus.

The write-side companion to /gaps and /missing-papers: those surface executable action descriptors, and this dispatches one to the underlying primitive and returns its structured result. Supported tools:

  • expand_corpus — acquire a cited-but-unowned work end-to-end (fetch + ingest + promote). args: {citation_id, zotero_key?}.
  • promote_citation — promote a citation to an owned source. args: {citation_id}.
  • expand_citations — pull a source's OpenAlex citation graph. args: {source?, doi?, direction?, limit?, dry_run?}.
  • claim with action="test" — test a hypothesis against the review's corpus (READ-ONLY). args: {text, top_k?}.

The three corpus-mutating tools are WRITE actions: they REJECT a federated (namespaced) review with 403, and are blocked with 403 when the server runs with ZK_DISABLE_WRITE (mirroring the MCP write-tool barrier — the underlying expand_corpus / promote_citation / expand_citations are @_write_tools). claim/test is read-only and allowed on a federated review, routed to that repo's scope (mirrors /test-claim). Missing review → 404 / corrupt → 422; an unsupported tool or missing arg → 400.

Source code in zettelkasten/dashboard/backend/routes/reviews.py
@router.post("/reviews/{name}/actions")
def execute_review_action(name: str, req: ExecuteActionRequest) -> dict:
    """Execute a gap/missing-paper ACTION affordance against the corpus.

    The write-side companion to ``/gaps`` and ``/missing-papers``: those surface
    executable ``action`` descriptors, and this dispatches one to the underlying
    primitive and returns its structured result. Supported tools:

    * ``expand_corpus`` — acquire a cited-but-unowned work end-to-end (fetch +
      ingest + promote). ``args``: ``{citation_id, zotero_key?}``.
    * ``promote_citation`` — promote a citation to an owned source. ``args``:
      ``{citation_id}``.
    * ``expand_citations`` — pull a source's OpenAlex citation graph. ``args``:
      ``{source?, doi?, direction?, limit?, dry_run?}``.
    * ``claim`` with ``action="test"`` — test a hypothesis against the review's
      corpus (READ-ONLY). ``args``: ``{text, top_k?}``.

    The three corpus-mutating tools are WRITE actions: they REJECT a federated
    (namespaced) review with 403, and are blocked with 403 when the server runs
    with ``ZK_DISABLE_WRITE`` (mirroring the MCP write-tool barrier — the
    underlying ``expand_corpus`` / ``promote_citation`` / ``expand_citations`` are
    ``@_write_tool``s). ``claim``/test is read-only and allowed on a federated
    review, routed to that repo's scope (mirrors ``/test-claim``). Missing review
    → 404 / corrupt → 422; an unsupported tool or missing arg → 400.
    """
    import zettelkasten.server as zk_server

    manifest, repo = _load_review_or_error(name)
    tool = (req.tool or "").strip()
    args = req.args or {}

    if tool in _ACTION_WRITE_TOOLS:
        if repo is not None:
            raise HTTPException(status_code=403, detail="Federated reviews are read-only.")
        if zk_server._WRITE_DISABLED:
            raise HTTPException(
                status_code=403,
                detail="Graph writes are disabled (ZK_DISABLE_WRITE); this action is unavailable.",
            )
        if tool in _ACTION_DELETE_TOOLS and zk_server._DELETE_DISABLED:
            raise HTTPException(
                status_code=403,
                detail="Graph deletes are disabled (ZK_DISABLE_DELETE); this action is unavailable.",
            )

    def _run_write(result_json: str) -> dict:
        # A mutating action can change a source's coverage/ownership, which the
        # papers and claims read-views derive from — drop their caches so the
        # next fetch reflects the new corpus (mirrors the coverage route).
        _papers_cache.clear()
        _claims_cache.clear()
        return json.loads(result_json)

    # Dispatch under a guard: the write tools already report failures in their
    # structured result, but an UNEXPECTED raise (a primitive TOCTOU, a races
    # mkdir, a bug) must not surface as a raw 500 traceback — mirror the tools'
    # "failure reported, never raised" contract with a clean structured error.
    # HTTPException (the 400/403/404 validation paths) is intentional and rides
    # through unchanged.
    try:
        if tool == "expand_corpus":
            cid = str(args.get("citation_id", "")).strip()
            if not cid:
                raise HTTPException(status_code=400, detail="expand_corpus requires 'citation_id'.")
            return _run_write(zk_server.expand_corpus(
                citation_id=cid, zotero_key=str(args.get("zotero_key", "")),
            ))

        if tool == "promote_citation":
            cid = str(args.get("citation_id", "")).strip()
            if not cid:
                raise HTTPException(status_code=400, detail="promote_citation requires 'citation_id'.")
            return _run_write(zk_server.promote_citation(cid))

        if tool == "expand_citations":
            return _run_write(zk_server.expand_citations(
                source=str(args.get("source", "")),
                doi=str(args.get("doi", "")),
                direction=str(args.get("direction", "both")),
                limit=int(args.get("limit", 25)),
                dry_run=bool(args.get("dry_run", False)),
            ))

        if tool == "claim" and (req.action or "").strip() == "test":
            text = str(args.get("text", "")).strip()
            if not text:
                raise HTTPException(status_code=400, detail="claim/test requires 'text'.")
            base = repo.zettel_dir if repo is not None else GRAPHS_DIR
            ns = (lambda s: federation.namespace_id(repo.id, s)) if repo is not None else (lambda s: s)
            loc = lambda d: _resolve_ref(d)[0]
            graph = manifest.get("graph", "") or ""
            project = manifest.get("project", "") or ""
            top_k = int(args.get("top_k", 0))
            return claim_producer.test_claim(
                _get_graph,
                text=text,
                project=("" if graph else project),
                graph=graph,
                top_k=top_k if top_k > 0 else claim_producer.TEST_MAX_CANDIDATES,
                graphs_dir=base,
                namespace=ns,
                localize=loc,
            )
    except HTTPException:
        raise
    except Exception as exc:
        return {
            "status": "failed",
            "tool": tool,
            "error": str(exc),
            "type": exc.__class__.__name__,
            "message": f"Action '{tool}' failed: {exc}",
        }

    raise HTTPException(status_code=400, detail=f"Unsupported action tool '{req.tool}'.")

review_claim_map

review_claim_map(name: str, limit: int = Query(200, ge=0, description='Max claim nodes to emit; 0 = none (mirrors /gaps)')) -> dict

Claim nodes + typed claim→claim edges + the narrative reading path (READ-ONLY).

Over the review's project/graph scope:

  • NODES — the scope's active claims (claims.build_claim_index), each with its derived status (claims.claim_status), capped to limit so an empty-scope review cannot dump the entire store. total is the uncapped count and truncated flags whether the map was clipped (limit=0 emits no nodes, mirroring how /gaps treats 0).
  • EDGES — the typed claim→claim relations (support / counter / structural) whose BOTH endpoints are in the CAPPED node set (tombstoned edges already dropped by the index), keyed by the <graph>::<id> claim uid.
  • NARRATIVE PATH — the foundations-first ordering the outline engine derives (outline.gather_outline_materialoutline.narrative_ordering), as a flat list of unit uids (themed sections in order, then unplaced), filtered to only uids present in the emitted nodes (the gather's claim-sparse fallback can emit non-claim source uids, which are not nodes). Best-effort: a gather failure yields an empty path rather than a 500.

Allowed on a federated review (read-only). Missing review → 404 / corrupt → 422.

Source code in zettelkasten/dashboard/backend/routes/reviews.py
@router.get("/reviews/{name}/claim-map")
def review_claim_map(
    name: str,
    limit: int = Query(
        200, ge=0, description="Max claim nodes to emit; 0 = none (mirrors /gaps)"
    ),
) -> dict:
    """Claim nodes + typed claim→claim edges + the narrative reading path (READ-ONLY).

    Over the review's ``project``/``graph`` scope:

    * NODES — the scope's active claims (``claims.build_claim_index``), each with
      its derived status (``claims.claim_status``), capped to ``limit`` so an
      empty-scope review cannot dump the entire store. ``total`` is the uncapped
      count and ``truncated`` flags whether the map was clipped (``limit=0``
      emits no nodes, mirroring how ``/gaps`` treats ``0``).
    * EDGES — the typed claim→claim relations (support / counter / structural)
      whose BOTH endpoints are in the CAPPED node set (tombstoned edges already
      dropped by the index), keyed by the ``<graph>::<id>`` claim uid.
    * NARRATIVE PATH — the foundations-first ordering the outline engine derives
      (``outline.gather_outline_material`` → ``outline.narrative_ordering``), as a
      flat list of unit uids (themed sections in order, then unplaced), filtered
      to only uids present in the emitted nodes (the gather's claim-sparse
      fallback can emit non-claim source uids, which are not nodes). Best-effort:
      a gather failure yields an empty path rather than a 500.

    Allowed on a federated review (read-only). Missing review → 404 / corrupt → 422.
    """
    manifest, repo = _load_review_or_error(name)
    base = repo.zettel_dir if repo is not None else GRAPHS_DIR
    ns = (lambda s: federation.namespace_id(repo.id, s)) if repo is not None else (lambda s: s)
    loc = lambda d: _resolve_ref(d)[0]
    graph = manifest.get("graph", "") or ""
    project = manifest.get("project", "") or ""
    scope_project = "" if graph else project

    index = claims.build_claim_index(
        _get_graph,
        project=scope_project,
        graph=graph,
        graphs_dir=base,
        namespace=ns,
        localize=loc,
    )

    # Bound the payload: cap the emitted nodes so an empty-scope review (which
    # walks every graph) cannot dump the entire store. ``total`` is the uncapped
    # count; ``truncated`` flags a clip. Edges and the narrative path are then
    # constrained to the capped node set so no endpoint/uid dangles.
    all_items = list(index.claims.items())
    total_nodes = len(all_items)
    capped_items = all_items[:limit]
    truncated = len(capped_items) < total_nodes

    nodes: list[dict] = []
    for key, note in capped_items:
        rc = index.resolved.get(key)
        nodes.append({
            "uid": claims.claim_uid(key),
            "id": key[1],
            "graph": key[0],
            "title": note.title,
            "type": note.type,
            "status": claims.claim_status(rc) if rc is not None else "",
        })

    claim_keys = {key for key, _ in capped_items}
    typed = (
        set(claims.SUPPORT_RELATIONS)
        | set(claims.COUNTER_RELATIONS)
        | set(claims.STRUCTURAL_RELATIONS)
    )
    edges: list[dict] = []
    seen_edges: set[tuple] = set()
    for e in index.edges:
        if e.relation not in typed:
            continue
        if e.src_key not in claim_keys or e.dst_key not in claim_keys:
            continue
        ekey = (e.src_graph, e.src_id, e.relation, e.dst_graph, e.dst_id)
        if ekey in seen_edges:
            continue
        seen_edges.add(ekey)
        edges.append({
            "source": claims.claim_uid(e.src_key),
            "target": claims.claim_uid(e.dst_key),
            "relation": e.relation,
        })

    narrative_path: list[str] = []
    try:
        material = outline.gather_outline_material(
            _get_graph,
            project=scope_project,
            graph=graph,
            graphs_dir=base,
            namespace=ns,
            localize=loc,
        )
        for section in material.sections:
            narrative_path.extend(b.uid for b in section.claims)
        narrative_path.extend(b.uid for b in material.unplaced)
    except Exception as exc:  # pragma: no cover - defensive; path is best-effort
        logger.warning("claim-map narrative path unavailable for '%s': %s", name, exc)
        narrative_path = []

    # Drop any narrative uid not in the emitted node set: the gather's claim-sparse
    # fallback can yield non-claim source uids (synthesis/finding/_cross), and the
    # node cap above can exclude a claim uid — either way a frontend join
    # narrative_path → nodes would dangle. Preserve order.
    node_uids = {n["uid"] for n in nodes}
    narrative_path = [uid for uid in narrative_path if uid in node_uids]

    result = {
        "scope": {"review": name, "project": project, "graph": graph},
        "nodes": nodes,
        "edges": edges,
        "narrative_path": narrative_path,
        "total": total_nodes,
        "truncated": truncated,
    }
    if repo is not None:
        result["read_only"] = True
        result["repo_id"] = repo.id
        result["repo_name"] = repo.name
    return result