Skip to content

zettelkasten.sharing.bundle

zettelkasten.sharing.bundle

On-disk bundle format for Zettelkasten sharing: filter -> encrypt (publish) and decrypt -> materialize a .zettelkasten cache.

Mirrors :mod:memory.sharing.bundle. A producer publishes a filtered, per-recipient-encrypted slice of its committed .zettelkasten store; a consumer git-fetches the bundle and decrypts only the units its key can open, materializing a .zettelkasten-shaped cache that :func:zettelkasten.federation.configured_repos surfaces like any other peer.

Unit kinds

v1 corpus kinds: note (a box *.md note), source_meta (a box _meta.yaml), project (a _projects/<name>.yaml manifest), and citation (a _citations/<id>.yaml entity).

v2 editorial kinds: review (a _reviews/<name>.yaml literature-review manifest+overlay), organization (a _organizations/<owner_type>/<owner_name>/<id>.json matrix definition), table (a review's _reviews/<name>.tables.json grid), and outline (a review's _reviews/<name>.outlines.json sidecar). The regenerable derived artifacts (a review/outline .md, an org index.json / _migrated.json / _ported_spines.json marker) are NOT shipped.

v3 binary/dataset kind: sidecar — a per-note BINARY blob shipped verbatim alongside its owning note, tagged by a subtype in the encrypted placement header. Three subtypes: equation_png (an equation note's committed <box>/_equations/<note_id>.png crop), dataset_file (a dataset note's committed <box>/<data.path> raw bytes for a plain local file), and dvc_pointer (only the <box>/<data.path>.dvc pointer — the tracked bytes stay in DVC and are pulled by the recipient). A dataset is treated as DVC (ships the pointer only) IFF an adjacent <data.path>.dvc exists on disk — mirroring :mod:zettelkasten.datasets, which distinguishes a DVC dataset by that pointer rather than the optional declared backend — so a sidecar/omitted-backend note with a sibling .dvc never ships raw bytes. A single sidecar kind (rather than three kinds) keeps ONE decrypt placement /reconcile path; the payload is raw bytes round-tripped with no scrub. An api-backed dataset's bytes live in the gitignored, re-derivable .angelo cache and are NEVER shipped (the note ships alone). Synapse and spine/source.node_id remain DEFERRED.

On-disk bundle layout

::

<bundle_root>/
    manifest.json          # cleartext structural index: [{kind, path}]
    units/<digest>.age     # one age ciphertext per shared unit

Each units/*.age is an age ciphertext of a small envelope: a single-line JSON header carrying the unit's PLACEMENT (kind + box/name/id) plus the newline-separated payload bytes (the scrubbed on-disk file). The placement lives INSIDE the ciphertext — the cleartext manifest carries only {kind, path} (no box/note ids/recipients), the same privacy-first design as memory. A note's box is NOT recoverable from its markdown alone, which is why it is carried in the encrypted header rather than the clear manifest. Unit filenames are sha256("<kind>:<unit_key>") digests: stable across republishes (good git deltas) and opaque to anyone who does not already know the id.

Filtering happens BEFORE any encryption (ungranted units are physically omitted, never encrypted-and-dropped), and each retained unit is scrubbed PER-UNIT of any reference to a unit not visible to all of its recipients (see :mod:zettelkasten.sharing.scrub). Revocation is forward-only (same accepted limitation as memory: a bundle repo's git history retains old ciphertexts).

BundleUnit dataclass

A pointer to one encrypted unit inside a bundle (kind + relative path).

Source code in zettelkasten/sharing/bundle.py
@dataclass(frozen=True)
class BundleUnit:
    """A pointer to one encrypted unit inside a bundle (``kind`` + relative path)."""

    kind: str
    path: str

    def to_dict(self) -> dict[str, str]:
        return {"kind": self.kind, "path": self.path}

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> "BundleUnit":
        return cls(kind=str(data.get("kind") or ""), path=str(data.get("path") or ""))

BundleManifest dataclass

The cleartext manifest.json at the bundle root.

Source code in zettelkasten/sharing/bundle.py
@dataclass(frozen=True)
class BundleManifest:
    """The cleartext ``manifest.json`` at the bundle root."""

    format: str
    format_version: str
    created_at: str
    units: tuple[BundleUnit, ...]
    producer_id: str | None = None

    def to_dict(self) -> dict[str, Any]:
        data: dict[str, Any] = {
            "format": self.format,
            "format_version": self.format_version,
            "created_at": self.created_at,
            "units": [u.to_dict() for u in self.units],
        }
        if self.producer_id:
            data["producer_id"] = self.producer_id
        return data

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> "BundleManifest":
        raw_units = data.get("units") or []
        units = tuple(BundleUnit.from_dict(u) for u in raw_units if isinstance(u, dict))
        producer = data.get("producer_id")
        return cls(
            format=str(data.get("format") or ""),
            format_version=str(data.get("format_version") or ""),
            created_at=str(data.get("created_at") or ""),
            units=units,
            producer_id=str(producer) if producer else None,
        )

BuildResult dataclass

Summary of a :func:build_bundle run (returned in memory, never written).

Source code in zettelkasten/sharing/bundle.py
@dataclass(frozen=True)
class BuildResult:
    """Summary of a :func:`build_bundle` run (returned in memory, never written)."""

    manifest: BundleManifest
    output_dir: Path
    note_unit_count: int
    source_unit_count: int
    project_unit_count: int
    citation_unit_count: int
    review_unit_count: int
    org_unit_count: int
    table_unit_count: int
    outline_unit_count: int
    sidecar_unit_count: int
    recipient_ids: tuple[str, ...]
    skipped_unregistered_ids: tuple[str, ...]
    # Number of notes covered by the sibling recipient-index export (0 when no
    # note ships or the export could not be written). See
    # :mod:`zettelkasten.synapse.sharing.recipient_index`.
    recipient_index_note_count: int = 0

DecryptResult dataclass

Summary of a :func:decrypt_bundle run.

Source code in zettelkasten/sharing/bundle.py
@dataclass(frozen=True)
class DecryptResult:
    """Summary of a :func:`decrypt_bundle` run."""

    output_dir: Path
    note_keys: tuple[str, ...]
    source_boxes: tuple[str, ...]
    project_names: tuple[str, ...]
    citation_ids: tuple[str, ...]
    review_names: tuple[str, ...]
    org_ids: tuple[str, ...]
    table_reviews: tuple[str, ...]
    outline_reviews: tuple[str, ...]
    sidecar_paths: tuple[str, ...]
    total_units: int

read_manifest

read_manifest(bundle_dir: Path | str) -> BundleManifest

Read and parse a bundle's cleartext manifest.json.

Source code in zettelkasten/sharing/bundle.py
def read_manifest(bundle_dir: Path | str) -> BundleManifest:
    """Read and parse a bundle's cleartext ``manifest.json``."""
    path = Path(bundle_dir) / MANIFEST_NAME
    data = json.loads(path.read_text(encoding="utf-8"))
    return BundleManifest.from_dict(data)

build_bundle

build_bundle(output_dir: Path | str, *, registry: IdentityRegistry, store_root: Path | str | None = None, sharing: SharingConfig | None = None, producer_id: str | None = None) -> BuildResult

Filter store units by grants, encrypt per recipient, and write a bundle.

Pipeline (filtering happens BEFORE any encryption):

  1. Enumerate note / source_meta / project / citation units from store_root (default: the local .zettelkasten store).
  2. Resolve each unit's recipient id set from sharing (default: parsed from store_root/config.yaml), expanding a firm scope to the registry members. Units resolving to no registered recipient are dropped (physically omitted).
  3. For each retained unit compute its ALLOWED set — the unit-keys whose recipient set is a superset of this unit's recipients — scrub cross-unit references down to that set (see :mod:zettelkasten.sharing.scrub), wrap the scrubbed bytes in a placement envelope, encrypt to the recipients' public keys and write units/<digest>.age.
  4. Write the cleartext manifest.json.

This function OWNS output_dir/units and output_dir/manifest.json — the units directory is rebuilt from scratch on every call so stale/revoked units never linger. Other files (e.g. .git) are left untouched. With no sharing policy configured it writes an empty bundle.

Source code in zettelkasten/sharing/bundle.py
def build_bundle(
    output_dir: Path | str,
    *,
    registry: IdentityRegistry,
    store_root: Path | str | None = None,
    sharing: SharingConfig | None = None,
    producer_id: str | None = None,
) -> BuildResult:
    """Filter store units by grants, encrypt per recipient, and write a bundle.

    Pipeline (filtering happens BEFORE any encryption):

    1. Enumerate ``note`` / ``source_meta`` / ``project`` / ``citation`` units
       from ``store_root`` (default: the local ``.zettelkasten`` store).
    2. Resolve each unit's recipient id set from ``sharing`` (default: parsed
       from ``store_root/config.yaml``), expanding a ``firm`` scope to the
       ``registry`` members. Units resolving to no registered recipient are
       dropped (physically omitted).
    3. For each retained unit compute its ALLOWED set — the unit-keys whose
       recipient set is a superset of this unit's recipients — scrub cross-unit
       references down to that set (see :mod:`zettelkasten.sharing.scrub`),
       wrap the scrubbed bytes in a placement envelope, encrypt to the
       recipients' public keys and write ``units/<digest>.age``.
    4. Write the cleartext ``manifest.json``.

    This function OWNS ``output_dir/units`` and ``output_dir/manifest.json`` —
    the units directory is rebuilt from scratch on every call so stale/revoked
    units never linger. Other files (e.g. ``.git``) are left untouched. With no
    sharing policy configured it writes an empty bundle.
    """
    output_dir = Path(output_dir)

    # Enumerate + recipient-resolve + filter (shared with project_for_caller):
    # store_root / sharing defaulting happens inside _collect_pending. This is the
    # OFFLINE P2P path: it collects ONLY ``offline`` units so a ``hosted`` unit is
    # never packaged into an offline bundle. With no transport declared everything
    # defaults ``offline``, so this filter is a no-op and the bundle is byte-for-
    # byte identical to before the transport tier existed.
    pending, bundled_recip, skipped_unregistered = _collect_pending(
        store_root, sharing, registry, {TRANSPORT_OFFLINE}
    )

    # ---- Prepare output (rebuild units/ from scratch) ---------------------
    units_dir = output_dir / UNITS_DIRNAME
    if units_dir.exists():
        shutil.rmtree(units_dir)
    units_dir.mkdir(parents=True, exist_ok=True)

    # ---- Per-unit scrub (superset-allowed) + encrypt ----------------------
    units: list[BundleUnit] = []
    all_recipient_ids: set[str] = set()
    counts = {
        KIND_NOTE: 0, KIND_SOURCE_META: 0, KIND_PROJECT: 0, KIND_CITATION: 0,
        KIND_REVIEW: 0, KIND_ORG: 0, KIND_TABLE: 0, KIND_OUTLINE: 0,
        KIND_SIDECAR: 0,
    }

    for kind, unit_key, header, payload, recips, pubkeys in _scrub_pending(
        pending, bundled_recip
    ):
        ciphertext = crypto.encrypt(_encode_envelope(header, payload), pubkeys)
        filename = _unit_filename(unit_key, kind)
        atomic_write(units_dir / filename, ciphertext)
        units.append(BundleUnit(kind=kind, path=f"{UNITS_DIRNAME}/{filename}"))
        counts[kind] += 1
        all_recipient_ids.update(
            rid for rid in recips if registry.public_key_for(rid)
        )

    # ---- 3. Write the cleartext manifest ----------------------------------
    manifest = BundleManifest(
        format=BUNDLE_FORMAT,
        format_version=BUNDLE_FORMAT_VERSION,
        created_at=datetime.now(timezone.utc).isoformat(),
        units=tuple(units),
        producer_id=producer_id,
    )
    atomic_write(output_dir / MANIFEST_NAME, json.dumps(manifest.to_dict(), indent=2))

    # ---- 4. Emit the firm-scoped recipient-index export -------------------
    # A sibling artifact naming, for EXACTLY the notes shipped above, which firm
    # recipients may open each — the enabler for a PEER's cross-producer synapse
    # edges (see :mod:`zettelkasten.synapse.sharing.recipient_index`). It reuses
    # the per-note recipients already resolved here (``bundled_recip`` KIND_NOTE),
    # is encrypted firm-wide, and is rebuilt from scratch (revoked notes drop
    # out). Kept best-effort: an export failure never aborts the bundle publish.
    recipient_index_note_count = 0
    try:
        from zettelkasten.synapse.sharing import recipient_index as _recipient_index

        shipped_note_recip = {
            uk: r for (k, uk), r in bundled_recip.items() if k == KIND_NOTE
        }
        # Sign the export with this producer's Ed25519 signing key so peers can
        # verify tamper-evident, non-repudiable binding to the producer. A missing
        # local identity / signing key -> publish UNSIGNED (build_recipient_index
        # warns); load_local_identity raising must never abort the bundle.
        signing_private_key: str | None = None
        try:
            _local_identity = load_local_identity()
            if _local_identity is not None:
                signing_private_key = _local_identity.signing_private_key
        except Exception as exc:  # pragma: no cover - defensive
            logger.warning(
                "could not load local identity for recipient-index signing: %s", exc
            )
        if not signing_private_key:
            logger.warning(
                "recipient-index export: no local signing key; publishing UNSIGNED"
            )
        ri_result = _recipient_index.build_recipient_index(
            output_dir, shipped_note_recip, registry, producer_id=producer_id,
            signing_private_key=signing_private_key,
        )
        recipient_index_note_count = ri_result.note_count
    except Exception as exc:  # pragma: no cover - defensive
        logger.warning("failed to write recipient-index export: %s", exc)

    return BuildResult(
        manifest=manifest,
        output_dir=output_dir,
        note_unit_count=counts[KIND_NOTE],
        source_unit_count=counts[KIND_SOURCE_META],
        project_unit_count=counts[KIND_PROJECT],
        citation_unit_count=counts[KIND_CITATION],
        review_unit_count=counts[KIND_REVIEW],
        org_unit_count=counts[KIND_ORG],
        table_unit_count=counts[KIND_TABLE],
        outline_unit_count=counts[KIND_OUTLINE],
        sidecar_unit_count=counts[KIND_SIDECAR],
        recipient_ids=tuple(sorted(all_recipient_ids)),
        skipped_unregistered_ids=tuple(sorted(skipped_unregistered)),
        recipient_index_note_count=recipient_index_note_count,
    )

decrypt_bundle

decrypt_bundle(bundle_dir: Path | str, out_root: Path | str, *, identity: LocalIdentity | Any | None = None, private_key: str | None = None) -> DecryptResult

Decrypt every unit this key can open and materialize a .zettelkasten cache.

Units are written into <out_root>/.zettelkasten/ as <box>/<id>.md (notes), <box>/_meta.yaml (source meta), _projects/<name>.yaml (projects) and _citations/<id>.yaml (citations), so :func:zettelkasten.federation.configured_repos renders the result like any other peer. Each unit's placement is read from its decrypted envelope header and validated / path-jailed before use.

The cache is RECONCILED to the current bundle: stale units (dropped from a newer, re-scoped bundle or no longer openable by this key) are evicted from each owned location. A transient I/O fault during the pass SKIPS eviction that run (never destroys valid cached plaintext); revocation still works on any clean pass. A missing/corrupt manifest is treated as an empty bundle (still reconciled); an unreadable manifest (transient I/O) aborts without reconciling.

Provide the key via identity (a :class:LocalIdentity / pyrage identity), private_key (an AGE-SECRET-KEY-... string), or neither (to load ~/.angelo/identity.yaml).

Source code in zettelkasten/sharing/bundle.py
def decrypt_bundle(
    bundle_dir: Path | str,
    out_root: Path | str,
    *,
    identity: LocalIdentity | Any | None = None,
    private_key: str | None = None,
) -> DecryptResult:
    """Decrypt every unit this key can open and materialize a ``.zettelkasten`` cache.

    Units are written into ``<out_root>/.zettelkasten/`` as
    ``<box>/<id>.md`` (notes), ``<box>/_meta.yaml`` (source meta),
    ``_projects/<name>.yaml`` (projects) and ``_citations/<id>.yaml``
    (citations), so :func:`zettelkasten.federation.configured_repos` renders the
    result like any other peer. Each unit's placement is read from its decrypted
    envelope header and validated / path-jailed before use.

    The cache is RECONCILED to the current bundle: stale units (dropped from a
    newer, re-scoped bundle or no longer openable by this key) are evicted from
    each owned location. A transient I/O fault during the pass SKIPS eviction
    that run (never destroys valid cached plaintext); revocation still works on
    any clean pass. A missing/corrupt manifest is treated as an empty bundle
    (still reconciled); an unreadable manifest (transient I/O) aborts without
    reconciling.

    Provide the key via ``identity`` (a :class:`LocalIdentity` / pyrage
    identity), ``private_key`` (an ``AGE-SECRET-KEY-...`` string), or neither (to
    load ``~/.angelo/identity.yaml``).
    """
    bundle_dir = Path(bundle_dir)
    zettel_dir = Path(out_root) / ".zettelkasten"
    key = _resolve_private_key(identity, private_key)

    try:
        manifest = read_manifest(bundle_dir)
    except FileNotFoundError as exc:
        logger.warning("bundle manifest absent (%s); reconciling cache as empty", exc)
        manifest = BundleManifest(format="", format_version="", created_at="", units=())
    except (json.JSONDecodeError, ValueError) as exc:
        logger.warning("bundle manifest corrupt (%s); reconciling cache as empty", exc)
        manifest = BundleManifest(format="", format_version="", created_at="", units=())
    except OSError as exc:
        logger.warning(
            "bundle manifest unreadable due to transient I/O (%s); aborting decrypt "
            "without reconciling to preserve valid cached plaintext", exc
        )
        return DecryptResult(
            output_dir=zettel_dir,
            note_keys=(),
            source_boxes=(),
            project_names=(),
            citation_ids=(),
            review_names=(),
            org_ids=(),
            table_reviews=(),
            outline_reviews=(),
            sidecar_paths=(),
            total_units=0,
        )

    # ---- Phase 1: decrypt everything this key can open, buffering writes ----
    # (dest, payload, identifier) per kind.
    note_writes: list[tuple[str, Path, bytes, str]] = []  # (box, dest, payload, key)
    meta_writes: list[tuple[str, Path, bytes]] = []       # (box, dest, payload)
    project_writes: list[tuple[Path, bytes, str]] = []
    citation_writes: list[tuple[Path, bytes, str]] = []
    review_writes: list[tuple[Path, bytes, str]] = []
    org_writes: list[tuple[Path, bytes, str]] = []
    table_writes: list[tuple[Path, bytes, str]] = []
    outline_writes: list[tuple[Path, bytes, str]] = []
    sidecar_writes: list[tuple[Path, bytes, str]] = []
    seen_notes: dict[str, set[str]] = {}   # box -> {filename}
    seen_meta_boxes: set[str] = set()
    seen_projects: set[str] = set()
    seen_citations: set[str] = set()
    # v2 editorial kinds are reconciled by their destination filename within the
    # dir they own (``_reviews`` holds three shapes; ``_organizations`` is nested).
    seen_reviews: set[str] = set()      # <name>.yaml
    seen_tables: set[str] = set()       # <name>.tables.json
    seen_outlines: set[str] = set()     # <name>.outlines.json
    seen_org_paths: set[Path] = set()   # resolved <id>.json dest paths
    transient_io_error = False

    for unit in manifest.units:
        unit_path = _resolve_unit_path(bundle_dir, unit)
        if unit_path is None:
            continue
        if not unit_path.exists():
            logger.warning("bundle unit missing on disk: %s", unit_path)
            continue
        try:
            ciphertext = unit_path.read_bytes()
        except OSError as exc:
            logger.warning(
                "could not read bundle unit %s (transient I/O): %s; "
                "skipping stale-eviction this run", unit_path, exc
            )
            transient_io_error = True
            continue
        try:
            plaintext = crypto.decrypt(ciphertext, key)
        except crypto.DecryptError:
            continue  # not encrypted to this key
        except Exception as exc:  # pragma: no cover - defensive
            logger.warning("failed to decrypt unit %s: %s", unit.path, exc)
            continue

        decoded = _decode_envelope(plaintext)
        if decoded is None:
            logger.warning("decrypted unit %s has a malformed envelope; skipping", unit.path)
            continue
        header, payload = decoded
        dest, kind, ident = _unit_dest(header, zettel_dir)
        if dest is None:
            logger.warning("decrypted unit %s has no usable placement; skipping", unit.path)
            continue

        if kind == KIND_NOTE:
            box = str(header.get("box"))
            note_writes.append((box, dest, payload, ident))
            seen_notes.setdefault(box, set()).add(dest.name)
        elif kind == KIND_SOURCE_META:
            box = str(header.get("box"))
            meta_writes.append((box, dest, payload))
            seen_meta_boxes.add(box)
        elif kind == KIND_PROJECT:
            project_writes.append((dest, payload, ident))
            seen_projects.add(dest.name)
        elif kind == KIND_CITATION:
            citation_writes.append((dest, payload, ident))
            seen_citations.add(dest.name)
        elif kind == KIND_REVIEW:
            review_writes.append((dest, payload, ident))
            seen_reviews.add(dest.name)
        elif kind == KIND_TABLE:
            table_writes.append((dest, payload, ident))
            seen_tables.add(dest.name)
        elif kind == KIND_OUTLINE:
            outline_writes.append((dest, payload, ident))
            seen_outlines.add(dest.name)
        elif kind == KIND_ORG:
            org_writes.append((dest, payload, ident))
            seen_org_paths.add(dest.resolve())
        elif kind == KIND_SIDECAR:
            sidecar_writes.append((dest, payload, ident))

    # ---- Phase 2: reconcile (evict stale) then write (additive) -----------
    # The set of binary/dataset sidecar idents (``"<box>/<rel>"``) materialized
    # THIS run — the provenance the reconcile evicts against (see below).
    current_shipped = {ident for _d, _p, ident in sidecar_writes}

    # Load the prior owned-sidecars provenance record BEFORE reconciling. A
    # transient read fault aborts ALL eviction this run (never deletes valid
    # cached plaintext), mirroring the per-unit ``transient_io_error`` guard; a
    # MISSING/corrupt record (first sync / legacy cache) yields an empty prior
    # set — start recording, evict nothing this run.
    prior_owned: set[str] = set()
    if not transient_io_error:
        prior_owned, record_ok = _load_owned_sidecars(zettel_dir)
        if not record_ok:
            transient_io_error = True

    # Eviction is gated on a clean pass so a transient fault never deletes valid
    # cached plaintext. The cache is EXCLUSIVELY produced by this function, so a
    # stale unit can be evicted by filename (like memory's ``entries/``).
    if not transient_io_error:
        # Cache-listing during reconcile — ``_note_graph_dirs`` plus the per-box
        # ``*.md`` glob and the ``_projects``/``_citations``/``_reviews``/
        # ``_organizations`` enumerations that compute what's on disk to evict —
        # can hit a transient ``OSError``. Mirror the per-unit/record transient
        # guard: on such a fault skip ALL eviction AND the record persist this
        # run, leaving the old ``.owned-sidecars.json`` and cached files intact
        # (revocation still works on any later clean pass). ``_unlink_quietly``
        # already swallows unlink faults, so only a listing fault trips this.
        try:
            boxes_to_reconcile = (
                set(seen_notes) | seen_meta_boxes | set(_note_graph_dirs(zettel_dir))
            )
            for box in boxes_to_reconcile:
                try:
                    box_dir = safe_join(zettel_dir, box)
                except ValueError:
                    continue
                if not box_dir.is_dir():
                    continue
                keep = seen_notes.get(box, set())
                for existing in box_dir.glob("*.md"):
                    if existing.name.startswith("_") or existing.name in keep:
                        continue
                    _unlink_quietly(existing)
                # A box's _meta.yaml is stale when this box has no shared meta now.
                if box not in seen_meta_boxes:
                    _unlink_quietly(box_dir / "_meta.yaml")
            _reconcile_dir(zettel_dir / "_projects", seen_projects, KIND_PROJECT)
            _reconcile_dir(zettel_dir / "_citations", seen_citations, KIND_CITATION)
            # ``_reviews`` holds three OWNED shapes (manifest .yaml, .tables.json,
            # .outlines.json); each is reconciled against its own seen-filename set
            # with a kind-specific owned-check, so a stale re-scoped review/table/
            # outline is evicted while a never-shipped ``.md`` (foreign) is preserved.
            reviews_dir = zettel_dir / "_reviews"
            if reviews_dir.is_dir():
                for existing in reviews_dir.glob("*.yaml"):
                    if existing.name not in seen_reviews and _is_owned_unit(existing, KIND_REVIEW):
                        _unlink_quietly(existing)
                for existing in reviews_dir.glob("*.tables.json"):
                    if existing.name not in seen_tables and _is_owned_unit(existing, KIND_TABLE):
                        _unlink_quietly(existing)
                for existing in reviews_dir.glob("*.outlines.json"):
                    if existing.name not in seen_outlines and _is_owned_unit(existing, KIND_OUTLINE):
                        _unlink_quietly(existing)
            _reconcile_orgs(zettel_dir / "_organizations", seen_org_paths)
        except OSError as exc:
            logger.warning(
                "cache listing failed during sidecar reconcile (transient I/O): "
                "%s; skipping eviction and record persist this run to preserve "
                "valid cached plaintext (revocation retries on a later clean pass)",
                exc,
            )
            transient_io_error = True

    # Evict binary/dataset sidecars LAST — only after EVERY directory
    # enumeration above (per-box ``*.md``, ``_projects``/``_citations``/
    # ``_reviews``/``_organizations``) has succeeded. Any transient listing
    # ``OSError`` there sets ``transient_io_error`` and this block is skipped, so
    # a fault evicts ZERO sidecar blobs (all-or-nothing) — leaving cache + record
    # intact for a later clean retry — instead of leaving the record old while the
    # bytes are already gone. Eviction is by PROVENANCE, not shape: EXACTLY the
    # paths we materialized before (``prior_owned``) that the new bundle no longer
    # ships (``prior_owned - current_shipped``). A path we never materialized — a
    # DVC-pulled data file, a foreign drop-in, a user's own file — is NEVER in
    # ``prior_owned`` and so is always preserved, regardless of its suffix. Each
    # stale ident is re-jailed before unlink.
    if not transient_io_error:
        for stale_ident in prior_owned - current_shipped:
            try:
                stale_path = safe_join(zettel_dir, *stale_ident.split("/"))
            except ValueError:
                continue
            _unlink_quietly(stale_path)

    for box, dest, payload, _ident in note_writes:
        atomic_write(dest, payload)
    for box, dest, payload in meta_writes:
        atomic_write(dest, payload)
    for dest, payload, _ident in project_writes:
        atomic_write(dest, payload)
    for dest, payload, _ident in citation_writes:
        atomic_write(dest, payload)
    for dest, payload, _ident in review_writes:
        atomic_write(dest, payload)
    for dest, payload, _ident in table_writes:
        atomic_write(dest, payload)
    for dest, payload, _ident in outline_writes:
        atomic_write(dest, payload)
    for dest, payload, _ident in org_writes:
        atomic_write(dest, payload)
    for dest, payload, _ident in sidecar_writes:
        atomic_write(dest, payload)

    # Persist this run's owned-sidecar provenance record (atomic) so the NEXT
    # decrypt evicts exactly what this run stops shipping. Only on a clean pass —
    # a transient fault leaves the prior record intact so revocation still works
    # on a later clean pass. An empty set removes the record rather than writing
    # ``[]`` (avoids materializing a spurious empty ``.zettelkasten`` dir on a
    # sidecar-free sync).
    if not transient_io_error:
        _persist_owned_sidecars(zettel_dir, current_shipped)

    return DecryptResult(
        output_dir=zettel_dir,
        note_keys=tuple(k for _b, _d, _p, k in note_writes),
        source_boxes=tuple(b for b, _d, _p in meta_writes),
        project_names=tuple(n for _d, _p, n in project_writes),
        citation_ids=tuple(c for _d, _p, c in citation_writes),
        review_names=tuple(n for _d, _p, n in review_writes),
        org_ids=tuple(n for _d, _p, n in org_writes),
        table_reviews=tuple(n for _d, _p, n in table_writes),
        outline_reviews=tuple(n for _d, _p, n in outline_writes),
        sidecar_paths=tuple(p for _d, _pl, p in sidecar_writes),
        total_units=len(manifest.units),
    )

open_bundle

open_bundle(bundle_dir: Path | str, out_root: Path | str, *, identity: LocalIdentity | Any | None = None, private_key: str | None = None) -> DecryptResult

Alias for :func:decrypt_bundle.

Source code in zettelkasten/sharing/bundle.py
def open_bundle(
    bundle_dir: Path | str,
    out_root: Path | str,
    *,
    identity: LocalIdentity | Any | None = None,
    private_key: str | None = None,
) -> DecryptResult:
    """Alias for :func:`decrypt_bundle`."""
    return decrypt_bundle(
        bundle_dir, out_root, identity=identity, private_key=private_key
    )