zettelkasten.synapse.sharing.bundle¶
zettelkasten.synapse.sharing.bundle ¶
On-disk bundle format for synapse-edge sharing: filter -> encrypt (publish)
and decrypt -> materialize a .synapse overlay cache.
Mirrors :mod:memory.sharing.bundle and :mod:zettelkasten.sharing.bundle, but
for the cross-store LINK OVERLAY (.synapse/links/links.json). A producer
publishes a filtered, per-recipient-encrypted slice of its local synapse edges; a
consumer git-fetches the bundle and decrypts only the edges its key can open,
REBUILDING a valid overlay into a federation cache
(.angelo/federation-cache/<peer>/.synapse) that
:func:zettelkasten.synapse.overlay.load_overlay / get_connections read like
any other overlay.
Id-secrecy is the whole point¶
A synapse edge is bipartite: it names BOTH a memory entry id and a ZK note id.
So it must ship ONLY to recipients who can already open BOTH endpoints — the
INTERSECTION of the two stores' per-unit recipient sets (see
:func:zettelkasten.synapse.sharing.config.edge_recipients). Filtering happens
BEFORE any encryption: an edge with no both-ends recipient (or an unresolvable
endpoint) is physically OMITTED, never encrypted-then-dropped.
Cross-producer (federated) edges¶
An edge whose zk_source contains ":" (e.g. "remote:alpha") names a ZK
note in a DIFFERENT producer's repo. Its ZK-end recipients cannot be computed
locally, so :func:build_bundle loads the peer's DECRYPTED recipient-index
policy from the federation cache (published by that peer alongside its ZK bundle;
see :mod:zettelkasten.synapse.sharing.recipient_index) and intersects it with
the local memory-entry recipients. This is strictly FAIL-CLOSED: if the peer
policy is missing, unresolvable, or does not contain the note, the edge is
OMITTED. Same-producer (non-":") edges are unaffected — their behaviour is
identical to before.
On-disk bundle layout¶
::
<bundle_root>/
manifest.json # cleartext structural index: [{kind, path}]
units/<digest>.age # one age ciphertext per shared edge
Each units/*.age is an age ciphertext of a small envelope: a single-line
JSON placement header ({kind, memory_id, zk_source, zk_id}) plus the
newline-separated edge-record JSON payload. The placement lives INSIDE the
ciphertext; the cleartext manifest.json carries only {kind, path} (no
ids, no recipients) — the same privacy-first design as the memory/zk bundles.
Unit filenames are sha256("<kind>:<unit_key>") digests (unit_key =
"<memory_id>:<zk_source>:<zk_id>"): stable across republishes and opaque to
anyone who does not already know both ids.
Deny-by-default payload allowlist¶
The edge JSON payload is NOT the raw edge dict — it is projected onto an EXPLICIT
field allowlist (:data:_SHARED_EDGE_FIELDS, built by
:func:_shareable_edge_payload), mirroring the filter-before-encrypt / scrub
discipline of the memory and zk bundles. Only known-safe fields ship; any field
NOT on the allowlist — including any FUTURE field an edge might grow in
:mod:zettelkasten.synapse.candidates or the typer — is silently omitted, so a
third-party id can never leak into a unit that reaches a one-sided-narrower
recipient. Specifically:
memory_id/zk_source/zk_idship for self-consistency but are advisory — :func:decrypt_bundleoverwrites them from the validated header.relation/confidence/created_atship (overlay needs relation+confidence;created_atis display-only).confidenceisfloat-coerced (and dropped if non-numeric) so the allowlist is not merely name-deep — a hostile value cannot smuggle an id under a numeric key.rationaleships: it is endpoint-derived free text, the same accepted body-leak class as an entry body in the parent memory/zk bundles. The typer that generates it is fed only a shared-provenance COUNT, never the dedicated rawshared_provenancetoken LIST (see :func:zettelkasten.synapse.typing_llm._build_prompt) — so the rationale can never echo a token that appears ONLY in that list (e.g. a memoryentry.filespath or a_citationstarget absent from either endpoint's text). It is NOT, however, "provably free of provenance tokens": a PATH-type token (e.g.foo/bar.py) is extracted from a ZK note BODY by :func:zettelkasten.synapse.candidates._zk_provenance, and the typer is fed that endpoint body verbatim, so such a token can appear in the rationale via the endpoint text. That is not a leak — the rationale stays bounded by the two endpoints' visible content, which a both-ends recipient can already read.signalsis reduced to its non-identifying numeric part —{"semantic_sim": <float>}plus ashared_provenance_count(an int). The rawshared_provenancetoken LIST is NEVER shipped; the count preserves the "N shared artifacts" signal while leaking nothing, and a both-ends recipient can recompute the tokens from the two endpoints it already holds.semantic_simis likewisefloat-coerced (dropped if non-numeric).
NOTE on overlay-shape divergence: a rebuilt federation-cache overlay's edge
signals are intentionally {semantic_sim, shared_provenance_count} and
DIVERGE from a natively-built overlay's {semantic_sim, shared_provenance}
(see :func:zettelkasten.synapse.overlay.build_connections). This is safe
today: no overlay consumer reads persisted signals (get_connections /
high_conf_neighbors only touch relation/confidence/ids). A FUTURE
consumer that reads signals must tolerate shared_provenance being
absent (replaced by shared_provenance_count) in a bundle-materialized
overlay.
Decrypt is a FULL REBUILD¶
The federation cache is EXCLUSIVELY bundle-owned, so each sync rebuilds
<cache_root>/.synapse/links/links.json from exactly the edges this key can
open — a revoked edge simply doesn't reappear next sync (forward-only revocation,
same accepted limitation as the parents: a bundle repo's git history retains old
ciphertexts). The rebuild is atomic and fail-safe: a transient read fault aborts
the rebuild WITHOUT touching the existing cache, so a blip never corrupts or
erases a good overlay.
Publish reflects each peer policy AS OF the last federation sync¶
build_bundle (synapse publish) resolves a FEDERATED edge's ZK-end
recipients from the peer's DECRYPTED recipient-index policy already sitting in the
federation cache — it never re-fetches the peer's export itself. So a cross-producer
edge reflects each peer's recipient policy exactly AS OF the consumer's LAST
angelo federation sync. Revocation is therefore FORWARD-ONLY and doubly gated:
a producer's revocation propagates only after the consumer (1) re-syncs (which
re-decrypts / removes the peer policy) AND (2) re-publishes off the refreshed
cache. Publish should follow sync: run angelo federation sync immediately
before synapse publish so the edges you ship reflect current grants. As an
observability aid (NOT a security control), publish emits a NON-FATAL stderr
WARNING — via :func:load_recipient_index_stamp — when a federated edge relies on
a peer policy whose freshness stamp is missing or older than
:data:_STALE_POLICY_WARN_AFTER; it never drops an edge and never enforces a hard
TTL. The warning tells the operator which peer policy is stale and to re-sync.
BundleUnit
dataclass
¶
A pointer to one encrypted edge unit (kind + relative path).
Source code in zettelkasten/synapse/sharing/bundle.py
BundleManifest
dataclass
¶
The cleartext manifest.json at the bundle root.
Source code in zettelkasten/synapse/sharing/bundle.py
BuildResult
dataclass
¶
Summary of a :func:build_bundle run (returned in memory, never written).
Source code in zettelkasten/synapse/sharing/bundle.py
DecryptResult
dataclass
¶
StalePeerPolicyError ¶
Bases: RuntimeError
Raised by :func:build_bundle under the OPT-IN fail-closed staleness TTL.
When max_policy_age_hours is configured, a synapse publish REFUSES to ship
cross-producer edges whose peer recipient-index policy is missing or older than
the TTL — a "publish-requires-recent-sync" gate — rather than emitting a
possibly over-permissive (stale) edge set. It is raised BEFORE any bundle
output is written, so an existing bundle is left intact on failure.
stale_peers is a list of (peer_id, decrypted_at_iso_or_None) for
programmatic handling; None means the peer's freshness stamp was
missing/unparseable.
Source code in zettelkasten/synapse/sharing/bundle.py
read_manifest ¶
read_manifest(bundle_dir: Path | str) -> BundleManifest
Read and parse a bundle's cleartext manifest.json.
Source code in zettelkasten/synapse/sharing/bundle.py
build_bundle ¶
build_bundle(output_dir: Path | str, *, registry: IdentityRegistry, memory_dir: Path | str | None = None, store_root: Path | str | None = None, overlay: Mapping[str, Any] | None = None, producer_id: str | None = None, federation_cache_root: Path | str | None = None, max_policy_age_hours: float | None = None) -> BuildResult
Filter synapse edges by both-ends grants, encrypt per recipient, write a bundle.
Pipeline (filtering happens BEFORE any encryption):
- Load the local
.synapseoverlay edges (overlayarg, else :func:zettelkasten.synapse.overlay.load_overlay). - Build the memory
entry_recipientsand ZKnote_recipientsmaps for the local workspace (reusing the two stores' sharing config loaders,firmexpanded to the registry members). For any FEDERATED edge, also load the referenced peer's decrypted recipient-index policy from the federation cache (federation_cache_root, default the workspace cache). - For each edge compute :func:
edge_recipients— the INTERSECTION of its two endpoints' recipient sets (the ZK end resolved locally for a same-producer edge, or from the peer policy for a federated one). OMIT the edge when it resolves to no recipient (either side missing/ungranted, a federated endpoint with no resolvable peer policy, or an empty intersection) or when none of its recipients is registered. Never encrypt-then-drop. - Encrypt each retained edge's placement-header envelope to its recipients'
public keys and write
units/<digest>.age. - Write the cleartext
manifest.jsonof{kind, path}pointers only.
This function OWNS output_dir/units and output_dir/manifest.json — the
units directory is rebuilt from scratch every call so stale/revoked units
never linger. Other files (e.g. .git) are left untouched. With no edges
or no sharing policy it writes an empty bundle.
max_policy_age_hours is the OPT-IN fail-closed staleness TTL (default
None = disabled). When set to a positive number and any cross-producer edge
is present, this raises :class:StalePeerPolicyError — BEFORE any output is
written — if a referenced peer's recipient policy is missing or older than the
TTL, refusing to publish a possibly over-permissive stale edge set. Left unset,
the default is unchanged: publish still ships (forward-only) and only emits a
non-fatal staleness warning.
Source code in zettelkasten/synapse/sharing/bundle.py
729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 | |
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 edge this key can open and REBUILD a .synapse overlay cache.
Edges are decrypted and their records collected, then a valid overlay file is
written to <out_root>/.synapse/links/links.json — a federation cache that
is ENTIRELY bundle-owned, so a full rebuild-from-decrypted is correct: a
revoked edge (dropped from a newer bundle, or no longer openable by this key)
simply does not reappear. The rebuild is atomic.
Durability: a MISSING/CORRUPT manifest is treated as an empty bundle (the overlay is rebuilt to zero edges — full revocation). An UNREADABLE manifest, or ANY transient I/O fault reading a unit, ABORTS the rebuild WITHOUT touching the existing cache — a transient blip must never corrupt or erase a good overlay; the next clean sync rebuilds normally.
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/synapse/sharing/bundle.py
990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 | |
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.