Skip to content

zettelkasten.sharing.scrub

zettelkasten.sharing.scrub

Per-unit id-secrecy scrubbers for Zettelkasten sharing.

Mirrors :func:memory.sharing.bundle._scrub_cross_links: before a unit is encrypted, any reference it carries to ANOTHER unit that is not visible to ALL of this unit's recipients is physically dropped, so a recipient can never learn (via a link/prerequisite/relation/membership) the id of a note/box/citation it was not itself granted.

Each scrubber takes the unit's raw on-disk bytes plus the KIND-SPECIFIC allowed sets it needs — the unit-keys of that kind that every recipient of this unit may also open (the superset-allowed set; see :func:zettelkasten.sharing.bundle.build_bundle). A reference must be resolved against the allowed set of the kind it actually points at (a source box's recipient set must never satisfy a same-named project reference, and vice versa), so the sets are passed per-kind rather than as one pooled allowed set. Unit-keys use the same scheme the resolvers produce:

  • a note -> "<box>/<note_id>" (allowed_notes)
  • a source -> "<box>" (the box name; allowed_sources)
  • a project-> "<project_name>"
  • a citation -> bare "<id>" (allowed_citations; the citation: prefix is stripped by the builder before it reaches a scrubber)

Every scrubber is a byte-for-byte NO-OP (returns the input unchanged) when it drops nothing, so a granted unit with no dangling references round-trips exactly. Citations carry no node-id cross-references and therefore need no scrub (encrypted verbatim by the builder).

scrub_note

scrub_note(raw: bytes, *, box: str, allowed_notes: set[str], allowed_citations: set[str], allowed_sources: set[str] | None = None) -> bytes

Drop a note's references to units outside its per-kind allowed sets.

allowed_notes holds the note unit-keys ("<box>/<id>"), allowed_citations the bare citation ids, and allowed_sources the box names (_meta unit-keys) that every recipient of this note may also open. allowed_sources is pooled with allowed_notes only to decide whether a BOX is safe to name (see :func:_nameable_boxes). Scrubbed fields:

  • links[] — an edge is dropped WHOLE when it does not resolve into the matching allowed set (see :func:_link_allowed): a _citations edge is checked against allowed_citations, every other edge against allowed_notes (same-box and cross-box).
  • prerequisites[] — a prerequisite (a same-box note id) is dropped when "<box>/<id>" is not in allowed_notes.
  • grounding.dataset_note — a data-citation note ref that may target a SIBLING box via grounding.dataset_graph OR an embedded id@graph form (see :func:zettelkasten.data_grounding._load_dataset_note, which partitions the ref on @ and lets dataset_graph — else the embedded segment — name the box). The effective box is dataset_graph or <embedded> or box and the effective ref is the part before @; it is resolved against "<effective_box>/<ref>". When that note is not in allowed_notes BOTH dataset_note and dataset_graph are dropped (dropping only dataset_note would leak the sibling box name via dataset_graph — or via the embedded @graph segment). When the note IS allowed the ref is kept, but if a dataset_graph is set AND the dataset_note embeds a DIFFERENT box after @ (which the loader ignores in favour of dataset_graph), dataset_note is rewritten to its bare pre-@ ref so that dead, un-checked embedded box name never ships. Other grounding.* fields are derivation metadata, not node ids, and are left untouched.
  • snapshot — the snapshot block on an equation note is a visual-provenance pointer that can name its source box in TWO independent places: the schema-optional source_graph locator AND the LEADING segment of path (a committed "<box>/_equations|_snapshots/<id>.png" sidecar from :func:zettelkasten.equation_snapshot._sidecar_path). Both are resolved INDEPENDENTLY (see :func:_snapshot_boxes) rather than gating on source_graph being truthy — otherwise the box would re-leak via path when source_graph is absent, or via a path naming a different un-shared box than a nameable source_graph. When EITHER resolved box is not nameable (see :func:_nameable_boxes: no note in allowed_notes and no _meta in allowed_sources), the scrub is DENY-BY-DEFAULT: the whole block is rebuilt keeping ONLY the non-locating page/rect provenance (_SNAPSHOT_SAFE_FIELDS), so source_graph + path + content_hash AND any other (possibly box-bearing) key are dropped together — closing the residual leak where a non-sidecar-shaped path or an extra box-bearing key bypassed a pop-known-keys scrub. The whole snapshot block is left unchanged (byte-identical) when every resolved box is nameable (or it carries no box-identifying field at all).

source.node_id (spine) is a node-id reference that is INTENTIONALLY left as-is (spine sharing is deferred). data.* storage pointers (backend/path/content_hash) are NOT box locators and are left intact, EXCEPT data.fetch.params.path — a producer-local filesystem locator (host-absolute or workspace-anchored; see :func:zettelkasten.datasets._fetch_file) that is dropped deny-by-default so a shared api dataset note never leaks the producer's filesystem layout (a shareable fetch.params.url is kept). Returns raw unchanged when nothing is dropped.

Source code in zettelkasten/sharing/scrub.py
def scrub_note(
    raw: bytes,
    *,
    box: str,
    allowed_notes: set[str],
    allowed_citations: set[str],
    allowed_sources: set[str] | None = None,
) -> bytes:
    """Drop a note's references to units outside its per-kind allowed sets.

    ``allowed_notes`` holds the note unit-keys (``"<box>/<id>"``),
    ``allowed_citations`` the bare citation ids, and ``allowed_sources`` the box
    names (``_meta`` unit-keys) that every recipient of this note may also open.
    ``allowed_sources`` is pooled with ``allowed_notes`` only to decide whether a
    BOX is safe to name (see :func:`_nameable_boxes`). Scrubbed fields:

    * ``links[]`` — an edge is dropped WHOLE when it does not resolve into the
      matching allowed set (see :func:`_link_allowed`): a ``_citations`` edge is
      checked against ``allowed_citations``, every other edge against
      ``allowed_notes`` (same-box and cross-box).
    * ``prerequisites[]`` — a prerequisite (a same-box note id) is dropped when
      ``"<box>/<id>"`` is not in ``allowed_notes``.
    * ``grounding.dataset_note`` — a data-citation note ref that may target a
      SIBLING box via ``grounding.dataset_graph`` OR an embedded ``id@graph``
      form (see :func:`zettelkasten.data_grounding._load_dataset_note`, which
      partitions the ref on ``@`` and lets ``dataset_graph`` — else the embedded
      segment — name the box). The effective box is
      ``dataset_graph or <embedded> or box`` and the effective ref is the part
      before ``@``; it is resolved against ``"<effective_box>/<ref>"``. When that
      note is not in ``allowed_notes`` BOTH ``dataset_note`` and ``dataset_graph``
      are dropped (dropping only ``dataset_note`` would leak the sibling box name
      via ``dataset_graph`` — or via the embedded ``@graph`` segment). When the
      note IS allowed the ref is kept, but if a ``dataset_graph`` is set AND the
      ``dataset_note`` embeds a DIFFERENT box after ``@`` (which the loader
      ignores in favour of ``dataset_graph``), ``dataset_note`` is rewritten to
      its bare pre-``@`` ref so that dead, un-checked embedded box name never
      ships. Other ``grounding.*`` fields are derivation metadata, not node ids,
      and are left untouched.
    * ``snapshot`` — the ``snapshot`` block on an equation note is a
      visual-provenance pointer that can name its source box in TWO independent
      places: the schema-optional ``source_graph`` locator AND the LEADING
      segment of ``path`` (a committed
      ``"<box>/_equations|_snapshots/<id>.png"`` sidecar from
      :func:`zettelkasten.equation_snapshot._sidecar_path`). Both are resolved
      INDEPENDENTLY (see :func:`_snapshot_boxes`) rather than gating on
      ``source_graph`` being truthy — otherwise the box would re-leak via ``path``
      when ``source_graph`` is absent, or via a ``path`` naming a different
      un-shared box than a nameable ``source_graph``. When EITHER resolved box is
      not nameable (see :func:`_nameable_boxes`: no note in ``allowed_notes`` and
      no ``_meta`` in ``allowed_sources``), the scrub is DENY-BY-DEFAULT: the
      whole block is rebuilt keeping ONLY the non-locating ``page``/``rect``
      provenance (``_SNAPSHOT_SAFE_FIELDS``), so ``source_graph`` + ``path`` +
      ``content_hash`` AND any other (possibly box-bearing) key are dropped
      together — closing the residual leak where a non-sidecar-shaped ``path`` or
      an extra box-bearing key bypassed a pop-known-keys scrub. The whole
      ``snapshot`` block is left unchanged (byte-identical) when every resolved
      box is nameable (or it carries no box-identifying field at all).

    ``source.node_id`` (spine) is a node-id reference that is INTENTIONALLY left
    as-is (spine sharing is deferred). ``data.*`` storage pointers
    (``backend``/``path``/``content_hash``) are NOT box locators and are left
    intact, EXCEPT ``data.fetch.params.path`` — a producer-local filesystem
    locator (host-absolute or workspace-anchored; see
    :func:`zettelkasten.datasets._fetch_file`) that is dropped deny-by-default so
    a shared ``api`` dataset note never leaks the producer's filesystem layout (a
    shareable ``fetch.params.url`` is kept). Returns ``raw`` unchanged when
    nothing is dropped.
    """
    try:
        text = raw.decode("utf-8")
    except UnicodeDecodeError:
        return raw
    match = _FRONTMATTER_RE.match(text)
    if not match:
        return raw
    try:
        meta = yaml.safe_load(match.group(1))
    except yaml.YAMLError:
        return raw
    if not isinstance(meta, dict):
        return raw

    changed = False

    raw_links = meta.get("links")
    if isinstance(raw_links, list):
        kept_links = []
        for link in raw_links:
            if not isinstance(link, dict):
                # Preserve malformed rows verbatim (nothing to resolve safely).
                kept_links.append(link)
                continue
            target = str(link.get("target") or "").strip()
            if not target:
                kept_links.append(link)
                continue
            if _link_allowed(box, target, link.get("graph", ""), allowed_notes, allowed_citations):
                kept_links.append(link)
            else:
                changed = True
        if changed:
            meta["links"] = kept_links

    raw_prereqs = meta.get("prerequisites")
    if isinstance(raw_prereqs, list):
        kept = [p for p in raw_prereqs if f"{box}/{str(p).strip()}" in allowed_notes]
        if len(kept) != len(raw_prereqs):
            meta["prerequisites"] = kept
            changed = True

    nameable_boxes = _nameable_boxes(allowed_notes, allowed_sources or set())

    grounding = meta.get("grounding")
    if isinstance(grounding, dict) and str(grounding.get("dataset_note") or "").strip():
        ds = str(grounding.get("dataset_note")).strip()
        ds_graph = str(grounding.get("dataset_graph") or "").strip()
        # A data-citation ref may carry an ``id@graph`` form; mirror
        # ``_load_dataset_note``'s box precedence (explicit dataset_graph wins,
        # else the embedded segment, else the current box) and resolve the bare
        # ref (before ``@``) against that box.
        ref, _, embedded = ds.partition("@")
        ref = ref.strip()
        embedded = embedded.strip()
        effective_box = ds_graph or embedded or box
        if f"{effective_box}/{ref}" not in allowed_notes:
            # Copy before mutating so the source dict is never touched. Drop the
            # dataset_graph locator too: leaving it (or the embedded ``@graph``
            # segment inside dataset_note) would leak an un-shared sibling box.
            grounding = dict(grounding)
            grounding.pop("dataset_note", None)
            grounding.pop("dataset_graph", None)
            meta["grounding"] = grounding
            changed = True
        elif ds_graph and embedded and embedded != ds_graph:
            # KEEP path, but dataset_note embeds a box after ``@`` that DIFFERS
            # from the dataset_graph actually resolving the ref. ``_load_dataset_note``
            # lets dataset_graph win, so the embedded segment is dead metadata —
            # and shipping it would leak that (un-checked) box name. Rewrite to the
            # bare pre-``@`` ref; dataset_graph preserves loader semantics.
            grounding = dict(grounding)
            grounding["dataset_note"] = ref
            meta["grounding"] = grounding
            changed = True

    snapshot = meta.get("snapshot")
    if isinstance(snapshot, dict):
        # Resolve the snapshot's source box from BOTH its ``source_graph`` locator
        # AND its ``path`` leading segment INDEPENDENTLY (see :func:`_snapshot_boxes`):
        # ``source_graph`` is schema-optional, but ``path`` carries the box name on
        # its own, so gating the scrub on ``source_graph`` being truthy would leak
        # the box via ``path`` (no source_graph) or via a ``path`` naming a
        # DIFFERENT un-shared box than a nameable ``source_graph``.
        snapshot_boxes = _snapshot_boxes(snapshot)
        if snapshot_boxes and any(b not in nameable_boxes for b in snapshot_boxes):
            # DENY-BY-DEFAULT: a resolvable source box is not nameable to every
            # recipient, so keep ONLY the known non-locating provenance fields
            # (``page``/``rect``) and drop everything else — ``source_graph``,
            # ``path``, ``content_hash`` (meaningless without its box) AND any
            # other (possibly box-bearing) key. Rebuilding from a whitelist
            # rather than popping the three known locators closes the residual
            # leak where a non-sidecar-shaped ``path`` or an extra box-bearing
            # key (e.g. ``origin_box``) slipped past a pop-known-keys scrub.
            meta["snapshot"] = {
                k: v for k, v in snapshot.items() if k in _SNAPSHOT_SAFE_FIELDS
            }
            changed = True

    # ``data.fetch.params.path`` on an ``api`` (``file``-provider) dataset note is
    # a PRODUCER-local filesystem path (host-absolute, or workspace-anchored — see
    # :func:`zettelkasten.datasets._fetch_file`) that a recipient can neither use
    # nor should see. Deny-by-default: drop it so a shared note never leaks the
    # producer's filesystem layout. Storage pointers (``backend``/``path``/
    # ``content_hash``) and a shareable ``fetch.params.url`` are left intact.
    data = meta.get("data")
    if isinstance(data, dict):
        fetch = data.get("fetch")
        params = fetch.get("params") if isinstance(fetch, dict) else None
        if isinstance(params, dict) and _is_host_path(params.get("path")):
            data = copy.deepcopy(data)
            data["fetch"]["params"].pop("path", None)
            meta["data"] = data
            changed = True

    if not changed:
        return raw
    return _rebuild(meta, match.group(2))

scrub_source_meta

scrub_source_meta(raw: bytes, *, allowed_sources: set[str]) -> bytes

Drop a source _meta.yaml's relations[] to boxes outside allowed_sources.

A source relation targets another SOURCE box, so a relation survives only when its target box is in allowed_sources (a bundled source visible to every recipient of this meta). A same-named PROJECT never satisfies the reference — only source-kind keys are consulted. Returns raw unchanged when nothing is dropped.

Source code in zettelkasten/sharing/scrub.py
def scrub_source_meta(raw: bytes, *, allowed_sources: set[str]) -> bytes:
    """Drop a source ``_meta.yaml``'s ``relations[]`` to boxes outside ``allowed_sources``.

    A source relation targets another SOURCE box, so a relation survives only
    when its ``target`` box is in ``allowed_sources`` (a bundled source visible
    to every recipient of this meta). A same-named PROJECT never satisfies the
    reference — only source-kind keys are consulted. Returns ``raw`` unchanged
    when nothing is dropped.
    """
    data = _load_yaml(raw)
    if not isinstance(data, dict):
        return raw
    raw_rel = data.get("relations")
    if not isinstance(raw_rel, list):
        return raw
    kept = [
        r for r in raw_rel
        if not isinstance(r, dict) or str(r.get("target") or "").strip() in allowed_sources
    ]
    if len(kept) == len(raw_rel):
        return raw
    data = dict(data)
    data["relations"] = kept
    return _dump_yaml(data)

scrub_project

scrub_project(raw: bytes, *, allowed_sources: set[str], allowed_notes: set[str]) -> bytes

Scrub a project manifest to its granted sources / cross notes.

  • sources[] — a source box survives only when it is NAMEABLE (see :func:_nameable_boxes): it has a bundled _meta in allowed_sources OR a bundled note in allowed_notes. Keying on _meta alone wrongly dropped a box the recipient can legitimately see via a shared note. A same-named project must never satisfy it — only source/note kinds are consulted.
  • cross[] — a _cross note id survives only when "_cross/<id>" is in allowed_notes (a bundled NOTE).
  • default_spine — blanked unconditionally (spine sharing is deferred, so a shared project never names an organizing spine).

Returns raw unchanged when nothing changes.

Source code in zettelkasten/sharing/scrub.py
def scrub_project(
    raw: bytes, *, allowed_sources: set[str], allowed_notes: set[str]
) -> bytes:
    """Scrub a project manifest to its granted sources / cross notes.

    * ``sources[]`` — a source box survives only when it is NAMEABLE (see
      :func:`_nameable_boxes`): it has a bundled ``_meta`` in ``allowed_sources``
      OR a bundled note in ``allowed_notes``. Keying on ``_meta`` alone wrongly
      dropped a box the recipient can legitimately see via a shared note. A
      same-named project must never satisfy it — only source/note kinds are
      consulted.
    * ``cross[]`` — a ``_cross`` note id survives only when ``"_cross/<id>"`` is
      in ``allowed_notes`` (a bundled NOTE).
    * ``default_spine`` — blanked unconditionally (spine sharing is deferred, so
      a shared project never names an organizing spine).

    Returns ``raw`` unchanged when nothing changes.
    """
    data = _load_yaml(raw)
    if not isinstance(data, dict):
        return raw

    changed = False
    new_data = dict(data)

    nameable_boxes = _nameable_boxes(allowed_notes, allowed_sources)

    raw_sources = data.get("sources")
    if isinstance(raw_sources, list):
        kept = [s for s in raw_sources if str(s).strip() in nameable_boxes]
        if len(kept) != len(raw_sources):
            new_data["sources"] = kept
            changed = True

    raw_cross = data.get("cross")
    if isinstance(raw_cross, list):
        kept = [c for c in raw_cross if f"_cross/{str(c).strip()}" in allowed_notes]
        if len(kept) != len(raw_cross):
            new_data["cross"] = kept
            changed = True

    spine = data.get("default_spine")
    if isinstance(spine, str) and spine.strip():
        new_data["default_spine"] = ""
        changed = True

    if not changed:
        return raw
    return _dump_yaml(new_data)

scrub_review

scrub_review(raw: bytes, *, allowed_notes: set[str], allowed_sources: set[str], allowed_citations: set[str], allowed_projects: set[str], allowed_orgs: set[str]) -> bytes

Scrub a review manifest (_reviews/<name>.yaml) of un-shared refs.

Manifest scalars are blanked when they name a unit not visible to every recipient: project (unless in allowed_projects), graph (unless the box is nameable), and spine (unless the org is in allowed_orgs). The overlay block — review-wide keys AND the default outline's per-outline keys — is scrubbed via :func:_scrub_editorial_overlay. The manifest's default-outline scaffold cache CAN embed corpus note ids (a claim_uid "<graph>::<note_id>" or a {graph, id} note ref), exactly like an outline sidecar's scaffold, so it is deep-scrubbed via :func:_deep_scrub_refs — the same backstop :func:scrub_outline applies. Returns raw unchanged when nothing is dropped.

Source code in zettelkasten/sharing/scrub.py
def scrub_review(
    raw: bytes,
    *,
    allowed_notes: set[str],
    allowed_sources: set[str],
    allowed_citations: set[str],
    allowed_projects: set[str],
    allowed_orgs: set[str],
) -> bytes:
    """Scrub a review manifest (``_reviews/<name>.yaml``) of un-shared refs.

    Manifest scalars are blanked when they name a unit not visible to every
    recipient: ``project`` (unless in ``allowed_projects``), ``graph`` (unless
    the box is nameable), and ``spine`` (unless the org is in ``allowed_orgs``).
    The ``overlay`` block — review-wide keys AND the default outline's per-outline
    keys — is scrubbed via :func:`_scrub_editorial_overlay`. The manifest's
    default-outline ``scaffold`` cache CAN embed corpus note ids (a ``claim_uid``
    ``"<graph>::<note_id>"`` or a ``{graph, id}`` note ref), exactly like an
    outline sidecar's ``scaffold``, so it is deep-scrubbed via
    :func:`_deep_scrub_refs` — the same backstop :func:`scrub_outline` applies.
    Returns ``raw`` unchanged when nothing is dropped.
    """
    data = _load_yaml(raw)
    if not isinstance(data, dict):
        return raw

    nameable_boxes = _nameable_boxes(allowed_notes, allowed_sources)
    changed = False
    new_data = dict(data)

    project = data.get("project")
    if isinstance(project, str) and project.strip() and project.strip() not in allowed_projects:
        new_data["project"] = ""
        changed = True

    graph = data.get("graph")
    if isinstance(graph, str) and graph.strip() and graph.strip() not in nameable_boxes:
        new_data["graph"] = ""
        changed = True

    spine = data.get("spine")
    if isinstance(spine, str) and spine.strip() and spine.strip() not in allowed_orgs:
        new_data["spine"] = ""
        changed = True

    overlay = data.get("overlay")
    if isinstance(overlay, dict):
        new_overlay, ov_changed = _scrub_editorial_overlay(
            overlay,
            allowed_notes=allowed_notes,
            nameable_boxes=nameable_boxes,
            allowed_citations=allowed_citations,
        )
        if ov_changed:
            new_data["overlay"] = new_overlay
            changed = True

    scaffold = data.get("scaffold")
    if isinstance(scaffold, (dict, list)):
        new_scaffold, sc_changed = _deep_scrub_refs(scaffold, allowed_notes)
        if sc_changed:
            new_data["scaffold"] = new_scaffold
            changed = True

    if not changed:
        return raw
    return _dump_yaml(new_data)

scrub_outline

scrub_outline(raw: bytes, *, allowed_notes: set[str], allowed_sources: set[str], allowed_citations: set[str], allowed_orgs: set[str]) -> bytes

Scrub an outline sidecar (_reviews/<name>.outlines.json) of un-shared refs.

The sidecar is {"outlines": {<id>: {id, title, question, spine, overlay, scaffold}}}. Each record's overlay is scrubbed via the shared :func:_scrub_editorial_overlay, its spine (an org id) is dropped when not in allowed_orgs, and its scaffold blob is deep-scrubbed as a backstop. Returns raw unchanged when nothing is dropped.

Source code in zettelkasten/sharing/scrub.py
def scrub_outline(
    raw: bytes,
    *,
    allowed_notes: set[str],
    allowed_sources: set[str],
    allowed_citations: set[str],
    allowed_orgs: set[str],
) -> bytes:
    """Scrub an outline sidecar (``_reviews/<name>.outlines.json``) of un-shared refs.

    The sidecar is ``{"outlines": {<id>: {id, title, question, spine, overlay,
    scaffold}}}``. Each record's ``overlay`` is scrubbed via the shared
    :func:`_scrub_editorial_overlay`, its ``spine`` (an org id) is dropped when
    not in ``allowed_orgs``, and its ``scaffold`` blob is deep-scrubbed as a
    backstop. Returns ``raw`` unchanged when nothing is dropped.
    """
    data = _load_json(raw)
    if not isinstance(data, dict):
        return raw
    outlines = data.get("outlines")
    if not isinstance(outlines, dict):
        return raw

    nameable_boxes = _nameable_boxes(allowed_notes, allowed_sources)
    changed = False
    for _oid, rec in outlines.items():
        if not isinstance(rec, dict):
            continue
        overlay = rec.get("overlay")
        if isinstance(overlay, dict):
            new_overlay, ov_changed = _scrub_editorial_overlay(
                overlay,
                allowed_notes=allowed_notes,
                nameable_boxes=nameable_boxes,
                allowed_citations=allowed_citations,
            )
            if ov_changed:
                rec["overlay"] = new_overlay
                changed = True
        spine = rec.get("spine")
        if isinstance(spine, str) and spine.strip() and spine.strip() not in allowed_orgs:
            rec["spine"] = None
            changed = True
        scaffold = rec.get("scaffold")
        if isinstance(scaffold, (dict, list)):
            new_scaffold, ch = _deep_scrub_refs(scaffold, allowed_notes)
            if ch:
                rec["scaffold"] = new_scaffold
                changed = True

    if not changed:
        return raw
    return _dump_json(data)

scrub_organization

scrub_organization(raw: bytes, *, allowed_notes: set[str], allowed_sources: set[str], allowed_citations: set[str], allowed_reviews: set[str]) -> bytes

Scrub an organization (_organizations/<owner_type>/<owner_name>/<id>.json).

The organization's OWN identity (id, owner) rides the encrypted placement header — the recipient is granted this org, whose address includes its owner (mirroring how a note's box is shipped in its header) — so owner is left intact. Cross-references are scrubbed: row_axis and columns graph/target-node refs (see :func:_scrub_axis/:func:_scrub_columns); spine_ref/last_spine_ref (synthesis box names, blanked when not nameable); review/table_id (blanked when the review is not in allowed_reviews); and the overlay. Every overlay correction is scrubbed deny-by-default: member_adds are dropped when they pin a note that is not bundled — including one with a BLANK graph — or target an un-nameable row_id box (see :func:_org_member_add_ok); attributions naming an un-shared note are dropped; and cell_overrides / member_removes (whose note_id is a bare id revealing no box, but whose row_id CAN encode a box) are dropped when their row_id box is not nameable (see :func:_row_id_box_ok). Author cell_overrides summaries and bare member_removes note ids on a NAMEABLE row are left. Returns raw unchanged on no-op.

Source code in zettelkasten/sharing/scrub.py
def scrub_organization(
    raw: bytes,
    *,
    allowed_notes: set[str],
    allowed_sources: set[str],
    allowed_citations: set[str],
    allowed_reviews: set[str],
) -> bytes:
    """Scrub an organization (``_organizations/<owner_type>/<owner_name>/<id>.json``).

    The organization's OWN identity (``id``, ``owner``) rides the encrypted
    placement header — the recipient is granted this org, whose address includes
    its owner (mirroring how a note's box is shipped in its header) — so ``owner``
    is left intact. Cross-references are scrubbed: ``row_axis`` and ``columns``
    graph/target-node refs (see :func:`_scrub_axis`/:func:`_scrub_columns`);
    ``spine_ref``/``last_spine_ref`` (synthesis box names, blanked when not
    nameable); ``review``/``table_id`` (blanked when the review is not in
    ``allowed_reviews``); and the ``overlay``. Every overlay correction is scrubbed
    deny-by-default: ``member_adds`` are dropped when they pin a note that is not
    bundled — including one with a BLANK ``graph`` — or target an un-nameable
    ``row_id`` box (see :func:`_org_member_add_ok`); ``attributions`` naming an
    un-shared note are dropped; and ``cell_overrides`` / ``member_removes`` (whose
    ``note_id`` is a bare id revealing no box, but whose ``row_id`` CAN encode a
    box) are dropped when their ``row_id`` box is not nameable (see
    :func:`_row_id_box_ok`). Author ``cell_overrides`` summaries and bare
    ``member_removes`` note ids on a NAMEABLE row are left. Returns ``raw``
    unchanged on no-op.
    """
    data = _load_json(raw)
    if not isinstance(data, dict):
        return raw

    nameable_boxes = _nameable_boxes(allowed_notes, allowed_sources)
    changed = False

    if _scrub_axis(data.get("row_axis"), nameable_boxes, allowed_notes):
        changed = True
    if _scrub_columns(data.get("columns"), nameable_boxes):
        changed = True

    for ref_key in ("spine_ref", "last_spine_ref"):
        val = data.get(ref_key)
        if isinstance(val, str) and val.strip() and val.strip() not in nameable_boxes:
            data[ref_key] = ""
            changed = True

    review = data.get("review")
    if isinstance(review, str) and review.strip() and review.strip() not in allowed_reviews:
        data["review"] = ""
        if str(data.get("table_id") or "").strip():
            data["table_id"] = ""
        changed = True

    overlay = data.get("overlay")
    if isinstance(overlay, dict):
        adds = overlay.get("member_adds")
        if isinstance(adds, list):
            kept = [a for a in adds if _org_member_add_ok(a, allowed_notes, nameable_boxes)]
            if len(kept) != len(adds):
                overlay["member_adds"] = kept
                changed = True
        # A member_remove / cell_override carries a bare ``note_id`` (no box) but a
        # ``row_id`` that CAN encode a box (source/note axis) — drop any whose row
        # box is not nameable, else the un-shared box name leaks via the row id.
        for key in ("member_removes", "cell_overrides"):
            entries = overlay.get(key)
            if isinstance(entries, list):
                kept = [
                    e for e in entries
                    if not isinstance(e, dict)
                    or _row_id_box_ok(e.get("row_id"), nameable_boxes, allowed_notes)
                ]
                if len(kept) != len(entries):
                    overlay[key] = kept
                    changed = True
        attrs = overlay.get("attributions")
        if isinstance(attrs, list):
            kept = [a for a in attrs if _org_attribution_ok(a, allowed_notes)]
            if len(kept) != len(attrs):
                overlay["attributions"] = kept
                changed = True

    if not changed:
        return raw
    return _dump_json(data)

scrub_table

scrub_table(raw: bytes, *, allowed_notes: set[str], allowed_sources: set[str]) -> 'bytes | object'

Scrub a review's table grid file (_reviews/<name>.tables.json).

The file is {"tables": {<table_id>: {row_axis, columns, rows, ...}}}. A table rides its review's recipients verbatim, and scrubbing is superset-gated, so a recipient who can see NONE of the corpus (a full-corpus review, or a review:<name> grant widening to a non-viewer) must not receive the grid SHELL — its un-shared box names, paper-title row labels, and verbatim cell quotes. Deny-by-default, mirroring :func:scrub_note's rebuild philosophy:

  • row_axis/columns graph refs are scrubbed (see :func:_scrub_axis/:func:_scrub_columns).
  • Each ROW whose box is not nameable to every recipient is DROPPED WHOLE (not shipped as a scrubbed shell; see :func:_row_nameable).
  • Each surviving row's cells are scrubbed of un-shared members + corpus-derived evidence/summary, and its transient material digest is dropped (see :func:_scrub_row).
  • attached_note_ids is filtered to ids still routed into a surviving member, so a dropped note id never ships.
  • A table whose rows are ALL dropped is OMITTED from the file; if that leaves the file with no tables at all, the whole unit is OMITTED (:data:OMIT_UNIT) so an empty/leaky shell is physically never shipped.

Returns raw unchanged when nothing is dropped, or :data:OMIT_UNIT when the unit must be physically omitted.

Source code in zettelkasten/sharing/scrub.py
def scrub_table(
    raw: bytes,
    *,
    allowed_notes: set[str],
    allowed_sources: set[str],
) -> "bytes | object":
    """Scrub a review's table grid file (``_reviews/<name>.tables.json``).

    The file is ``{"tables": {<table_id>: {row_axis, columns, rows, ...}}}``. A
    table rides its review's recipients verbatim, and scrubbing is superset-gated,
    so a recipient who can see NONE of the corpus (a full-corpus review, or a
    ``review:<name>`` grant widening to a non-viewer) must not receive the grid
    SHELL — its un-shared box names, paper-title row ``label``s, and verbatim cell
    quotes. Deny-by-default, mirroring :func:`scrub_note`'s rebuild philosophy:

    * ``row_axis``/``columns`` graph refs are scrubbed (see
      :func:`_scrub_axis`/:func:`_scrub_columns`).
    * Each ROW whose box is not nameable to every recipient is DROPPED WHOLE (not
      shipped as a scrubbed shell; see :func:`_row_nameable`).
    * Each surviving row's cells are scrubbed of un-shared members + corpus-derived
      evidence/summary, and its transient ``material`` digest is dropped (see
      :func:`_scrub_row`).
    * ``attached_note_ids`` is filtered to ids still routed into a surviving
      member, so a dropped note id never ships.
    * A table whose rows are ALL dropped is OMITTED from the file; if that leaves
      the file with no tables at all, the whole unit is OMITTED
      (:data:`OMIT_UNIT`) so an empty/leaky shell is physically never shipped.

    Returns ``raw`` unchanged when nothing is dropped, or :data:`OMIT_UNIT` when
    the unit must be physically omitted.
    """
    data = _load_json(raw)
    if not isinstance(data, dict):
        return raw
    tables = data.get("tables")
    if not isinstance(tables, dict):
        return raw

    nameable_boxes = _nameable_boxes(allowed_notes, allowed_sources)
    changed = False
    surviving: dict[str, Any] = {}
    for tid, table in tables.items():
        if not isinstance(table, dict):
            surviving[tid] = table
            continue
        if _scrub_axis(table.get("row_axis"), nameable_boxes, allowed_notes):
            changed = True
        if _scrub_columns(table.get("columns"), nameable_boxes):
            changed = True

        visible_ids: set[str] = set()
        rows = table.get("rows")
        had_rows = isinstance(rows, list) and len(rows) > 0
        if isinstance(rows, list):
            kept_rows: list[Any] = []
            for row in rows:
                if not isinstance(row, dict):
                    kept_rows.append(row)
                    continue
                if not _row_nameable(row, nameable_boxes, allowed_notes):
                    changed = True
                    continue  # drop the whole un-nameable row (no scrubbed shell)
                if _scrub_row(row, allowed_notes, nameable_boxes, visible_ids):
                    changed = True
                kept_rows.append(row)
            if len(kept_rows) != len(rows):
                table["rows"] = kept_rows
                changed = True

        attached = table.get("attached_note_ids")
        if isinstance(attached, list):
            kept = [a for a in attached if str(a).strip() in visible_ids]
            if len(kept) != len(attached):
                table["attached_note_ids"] = kept
                changed = True

        # A table whose every row was dropped is OMITTED from the file entirely
        # (not shipped as an empty shell). An already-empty table is left as-is so
        # the pure no-op path stays byte-identical.
        if had_rows and not table.get("rows"):
            continue
        surviving[tid] = table

    if not surviving:
        # Every table drained to nothing: omit the whole unit — but only when it
        # ACTUALLY carried tables (an already-empty file stays a byte-identical
        # no-op rather than being spuriously omitted).
        return OMIT_UNIT if tables else raw
    if not changed:
        return raw
    data["tables"] = surviving
    return _dump_json(data)

note_citation_refs

note_citation_refs(raw: bytes) -> set[str]

Citation ids a note references via links[].graph == "_citations".

Used by the builder to resolve citation recipients (a citation rides the union of the recipients of every note that cites it). Returns an empty set for an unparseable note.

Source code in zettelkasten/sharing/scrub.py
def note_citation_refs(raw: bytes) -> set[str]:
    """Citation ids a note references via ``links[].graph == "_citations"``.

    Used by the builder to resolve citation recipients (a citation rides the
    union of the recipients of every note that cites it). Returns an empty set
    for an unparseable note.
    """
    try:
        text = raw.decode("utf-8")
    except UnicodeDecodeError:
        return set()
    match = _FRONTMATTER_RE.match(text)
    if not match:
        return set()
    try:
        meta = yaml.safe_load(match.group(1))
    except yaml.YAMLError:
        return set()
    if not isinstance(meta, dict):
        return set()
    out: set[str] = set()
    for link in meta.get("links") or []:
        if isinstance(link, dict) and str(link.get("graph") or "").strip() == "_citations":
            target = str(link.get("target") or "").strip()
            if target:
                out.add(target)
    return out