memory.sharing¶
memory.sharing ¶
Encryption-first firm-wide sharing: config, identity, crypto, and bundles.
This package is the foundation for sharing a filtered, per-recipient-encrypted slice of a memory tree with other machines/teams. It is a library only — the publish/sync CLI is wired separately on top of this stable API.
The three layers:
- Policy (:mod:
~memory.sharing.config) — parse the owner-authoredsharing/grantsconfig and resolve which entries are shareable to whom. - Identity (:mod:
~memory.sharing.identity) — keypair generation, the local~/.angelo/identity.yaml(private key, never committed), and the public-key identity registry. - Crypto + bundle (:mod:
~memory.sharing.crypto, :mod:~memory.sharing.bundle) —ageencrypt/decrypt and the on-disk bundle format (filter -> encrypt -> package, then decrypt -> materialize a.memorytree that :func:memory.federation.read_repocan read unchanged).
Typical producer flow::
from memory import sharing
registry = sharing.load_registry()
result = sharing.build_bundle("/path/to/bundle-repo", registry=registry)
Typical consumer flow::
from memory import sharing
sharing.decrypt_bundle(
"/path/to/fetched-bundle-repo",
"/path/to/federation-cache/peer/.memory",
) # uses ~/.angelo/identity.yaml
BuildResult
dataclass
¶
Summary of a :func:build_bundle run (returned in memory, never written).
Source code in memory/sharing/bundle.py
BundleManifest
dataclass
¶
The cleartext manifest.json at the bundle root.
Source code in memory/sharing/bundle.py
BundleUnit
dataclass
¶
A pointer to one encrypted unit inside a bundle.
kind is one of "entry" / "document" / "skill" / "session"
(opaque units/<sha256>.age files) or "project" (the project.age
stub); path is the unit's location relative to the bundle root. No id or
recipient info is stored here — that is the deliberate privacy-first manifest
design (see module docstring).
Source code in memory/sharing/bundle.py
DecryptResult
dataclass
¶
Summary of a :func:decrypt_bundle run.
Source code in memory/sharing/bundle.py
Scope
dataclass
¶
A visibility scope attached to a subtree node.
audience is only meaningful when kind == 'bilateral' and holds the
recipient ids that may access the subtree.
Source code in memory/sharing/config.py
SharingConfig
dataclass
¶
Parsed, typed view of the sharing + grants config blocks.
scopesmaps a node id (subproject/phase/entry) to its :class:Scope.grantsmaps a node id to the tuple of recipient ids granted access to that node and its descendants. Grants also carry the out-of-tree kinds:grants['skill:<name>']grants a specific skill andgrants['doc:<id>'](reserved) a specific document.skills_defaultis the global default :class:Scopefor skills (which live OUTSIDE the entry tree, so they cannot inherit a subtree scope). It defaults toprivate— skills are shared with nobody unless the owner setssharing.skillsor askill:<name>grant.
Source code in memory/sharing/config.py
is_empty ¶
True when no sharing policy is configured (today's behaviour).
ProjectionResult
dataclass
¶
Summary of a :func:project_for_caller run.
Mirrors the useful fields of :class:memory.sharing.bundle.DecryptResult
(the projection is defined to equal what that caller would decrypt), plus the
caller_id it was scoped to. total_units counts the entry/document/
skill/session units in this caller's view (the project stub is reported
separately via project_written).
Source code in memory/sharing/projection.py
IdentityRegistry
dataclass
¶
An id -> RegistryEntry map of publishable (public-key) identities.
Source code in memory/sharing/identity.py
member_ids ¶
public_key_for ¶
The public key for recipient_id, or None if unregistered.
signing_public_key_for ¶
The Ed25519 signing public key for recipient_id.
Returns None when the id is unregistered or when the registered
entry predates signing (legacy entry with no signing_public_key).
Source code in memory/sharing/identity.py
public_keys_for ¶
Resolve ids to public keys, silently dropping unregistered ids.
Source code in memory/sharing/identity.py
with_entry ¶
with_entry(entry: RegistryEntry) -> 'IdentityRegistry'
A copy of this registry with entry added/overwritten (immutably).
KeyPair
dataclass
¶
A freshly generated identity keypair (encryption + signing).
public_key is an age1... recipient string and private_key an
AGE-SECRET-KEY-... string — the age X25519 keypair used for
encryption. age has no signing primitive, so an independent Ed25519
signing keypair rides alongside it (URL-safe base64 of the raw key bytes;
see :mod:memory.sharing.signing). The signing fields are optional and
default to None so legacy callers/keypairs without a signing key keep
working. Guard the private keys like passwords.
Source code in memory/sharing/identity.py
LocalIdentity
dataclass
¶
This machine's identity: a logical id plus its keypair(s).
Persisted (with the private keys) to ~/.angelo/identity.yaml. Carries both
the age X25519 encryption keypair and the Ed25519 signing keypair; the
signing fields are optional (default None) so a legacy identity file with
no signing key still loads.
Source code in memory/sharing/identity.py
to_pyrage_identity ¶
to_registry_entry ¶
to_registry_entry() -> RegistryEntry
The public-only :class:RegistryEntry for publishing to the registry.
Source code in memory/sharing/identity.py
RegistryEntry
dataclass
¶
One published identity: a logical id bound to a public key.
display_name and github are optional and used only for attribution /
discovery — never for access control (the public key is the anchor).
signing_public_key is the identity's Ed25519 signing public key (base64,
public part only); optional so legacy registries without it still parse.
Source code in memory/sharing/identity.py
build_bundle ¶
build_bundle(output_dir: Path | str, *, registry: IdentityRegistry, source_memory_dir: Path | str | None = None, sharing: SharingConfig | None = None, producer_id: str | None = None, include_discarded: bool = False) -> BuildResult
Filter memory entries by grants, encrypt per recipient, and write a bundle.
Pipeline (filtering happens BEFORE any encryption — ungranted/private entries are physically omitted, never encrypted-and-dropped):
- Read entries + project from
source_memory_dir(default: the local.memorystore). - Resolve each entry's recipient id set from
sharing(default: parsed from the sourceconfig.yaml), expanding afirmscope to theregistrymembers. Entries resolving to no recipients are dropped. - Encrypt each retained entry's on-disk markdown to its recipients' public
keys and write
units/<digest>.age. Before encryption, cross-link frontmatter fields (related_to/invalidates/invalidated_by/parent_id) are scrubbed PER UNIT: a reference to id X survives only if X is itself bundled AND every recipient of this unit is also a recipient of X (plus the shared project root is always allowed). This keeps a recipient from ever learning the id/relationship of an entry it was not also granted, and is a byte-level no-op for a granted entry that has no dangling cross-links. - Encrypt a MINIMAL
project.mdSTUB to the UNION of all recipients and writeproject.age. The stub keeps only structural frontmatter (id/name/status/created_at) with an EMPTY body — enough forread_repo/configured_reposto render the peer, but WITHOUT the project root description/body a bilateral recipient was never granted. - Write the cleartext
manifest.json.
This function OWNS output_dir/units, output_dir/project.age and
output_dir/manifest.json — the units directory is rebuilt from scratch on
every call so stale/revoked units never linger. Other files in output_dir
(e.g. a .git directory) are left untouched.
Returns a :class:BuildResult. With no sharing policy configured this writes
an empty bundle (no units, no project) — the additive "nothing shared"
default.
Source code in memory/sharing/bundle.py
1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174 1175 1176 1177 1178 1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 1191 1192 1193 1194 1195 1196 1197 1198 1199 1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210 1211 1212 1213 1214 1215 1216 1217 1218 1219 1220 1221 1222 1223 1224 1225 1226 1227 1228 1229 | |
decrypt_bundle ¶
decrypt_bundle(bundle_dir: Path | str, output_memory_dir: Path | str, *, identity: LocalIdentity | Any | None = None, private_key: str | None = None) -> DecryptResult
Decrypt every unit this key can open and materialize a .memory tree.
For each manifest unit the private key is tried; units that fail to decrypt
(i.e. were not encrypted to this key) are silently skipped. Successfully
decrypted units are written into a .memory-shaped output_memory_dir
(project.md plus entries/<id>.md, documents/<id>.md,
sessions/<id>.md and skills/<name>.md) so that
:func:memory.federation.read_repo can read the result unchanged.
Each unit's id/name is read from the decrypted frontmatter (it is never
stored in the clear) and validated / path-jailed before being used as a
filename, so a hostile bundle cannot escape output_memory_dir.
Each cleartext manifest.json unit path is validated BEFORE it is read
(rejecting absolute paths, .. traversal, and anything but the opaque
units/<sha256>.age / project.age layout — enforced regardless of the
manifest's self-declared format) so a malicious publisher cannot use this
function to read arbitrary local files. A missing or corrupt manifest is
treated as an empty bundle (the cache is still reconciled), never a crash.
The output .memory tree is RECONCILED to the current bundle: stale files
(units that dropped out of a newer, re-scoped bundle or are no longer openable
by this key) are removed from EACH owned dir (entries/, documents/,
skills/, sessions/), and project.md is refreshed or removed to
match. Only the .md files in those four dirs and project.md this
function owns are touched — unrelated files (e.g. .git) are left alone. A
transient I/O fault during the pass SKIPS all eviction that run (never
destroys valid cached plaintext); revocation still works on any clean pass.
Provide the key via identity (a :class:LocalIdentity or pyrage
identity), private_key (an AGE-SECRET-KEY-... string), or neither (to
load ~/.angelo/identity.yaml).
Source code in memory/sharing/bundle.py
1328 1329 1330 1331 1332 1333 1334 1335 1336 1337 1338 1339 1340 1341 1342 1343 1344 1345 1346 1347 1348 1349 1350 1351 1352 1353 1354 1355 1356 1357 1358 1359 1360 1361 1362 1363 1364 1365 1366 1367 1368 1369 1370 1371 1372 1373 1374 1375 1376 1377 1378 1379 1380 1381 1382 1383 1384 1385 1386 1387 1388 1389 1390 1391 1392 1393 1394 1395 1396 1397 1398 1399 1400 1401 1402 1403 1404 1405 1406 1407 1408 1409 1410 1411 1412 1413 1414 1415 1416 1417 1418 1419 1420 1421 1422 1423 1424 1425 1426 1427 1428 1429 1430 1431 1432 1433 1434 1435 1436 1437 1438 1439 1440 1441 1442 1443 1444 1445 1446 1447 1448 1449 1450 1451 1452 1453 1454 1455 1456 1457 1458 1459 1460 1461 1462 1463 1464 1465 1466 1467 1468 1469 1470 1471 1472 1473 1474 1475 1476 1477 1478 1479 1480 1481 1482 1483 1484 1485 1486 1487 1488 1489 1490 1491 1492 1493 1494 1495 1496 1497 1498 1499 1500 1501 1502 1503 1504 1505 1506 1507 1508 1509 1510 1511 1512 1513 1514 1515 1516 1517 1518 1519 1520 1521 1522 1523 1524 1525 1526 1527 1528 1529 1530 1531 1532 1533 1534 1535 1536 1537 1538 | |
open_bundle ¶
open_bundle(bundle_dir: Path | str, output_memory_dir: Path | str, *, identity: LocalIdentity | Any | None = None, private_key: str | None = None) -> DecryptResult
Alias for :func:decrypt_bundle (decrypt + materialize a .memory tree).
Source code in memory/sharing/bundle.py
read_manifest ¶
read_manifest(bundle_dir: Path | str) -> BundleManifest
Read and parse a bundle's cleartext manifest.json.
Source code in memory/sharing/bundle.py
document_recipients ¶
document_recipients(sharing: SharingConfig, documents: Iterable[Mapping[str, Any]], entry_recipients_map: Mapping[str, set[str]]) -> dict[str, set[str]]
Map each shareable document id to its recipient set.
A document rides along with the entry it documents: it is shareable to a
recipient R iff EVERY entry named in its links_to is bundled (present in
entry_recipients_map) AND R can see all of them. The recipient set is
therefore the INTERSECTION of the links_to targets' recipient sets — this
guarantees that any retained links_to target is visible to every
recipient of the document (so the id never leaks; see the per-unit scrub in
:mod:~memory.sharing.bundle).
A document is OMITTED (returns no entry) when it has no links_to (orphan)
or any target is itself omitted/private. Only status == "active"
documents are considered. Per-document grants['doc:<id>'] overrides are
intentionally NOT honoured here (see the module notes): widening a document's
audience beyond its link target's would force the links_to to be scrubbed
for the extra recipients, defeating the document's purpose. Deferred.
Source code in memory/sharing/config.py
effective_grants ¶
effective_grants(entry: Mapping[str, Any], entries_by_id: Mapping[str, Mapping[str, Any]], sharing: SharingConfig) -> set[str]
Recipient ids explicitly granted to entry via any ancestor grant.
Source code in memory/sharing/config.py
effective_scope ¶
effective_scope(entry: Mapping[str, Any], entries_by_id: Mapping[str, Mapping[str, Any]], sharing: SharingConfig) -> Scope
Resolve the scope that applies to entry after subtree inheritance.
Source code in memory/sharing/config.py
entry_recipients ¶
entry_recipients(sharing: SharingConfig, entries: Iterable[Mapping[str, Any]], firm_members: Iterable[str] = (), *, index_entries: Iterable[Mapping[str, Any]] | None = None) -> dict[str, set[str]]
Map each shareable entry id to the concrete set of recipient ids.
Unlike :func:is_shareable_to, this EXPANDS a firm scope to the actual
firm_members (the registered identities) so the bundle builder knows the
exact public keys to encrypt to. Entries that resolve to no recipients
(private and ungranted) are omitted from the result — the caller uses
this to physically drop them from the bundle before any encryption.
entries are the entries actually considered for bundling (typically the
ACTIVE entries). index_entries is the pool used to build the
ancestor-resolution index — pass ALL entries (including discarded/any
status) here so scope/grant inheritance can traverse a discarded
intermediate plan node. When index_entries is None the index is
built from entries (backwards-compatible behaviour). Only ids in
entries are resolved and returned, so a discarded ancestor never gets
bundled itself — it only keeps the chain from its active descendants intact.
Source code in memory/sharing/config.py
is_shareable_to ¶
is_shareable_to(entry: Mapping[str, Any], entries_by_id: Mapping[str, Mapping[str, Any]], sharing: SharingConfig, recipient_id: str) -> bool
Whether entry is shareable to recipient_id.
Note: a firm-scoped entry is shareable to ANY recipient id here — firm
membership is enforced by the registry at bundle-build time (see
:func:entry_recipients), not by this logical query.
Source code in memory/sharing/config.py
parse_sharing_config ¶
parse_sharing_config(config: Mapping[str, Any] | None) -> SharingConfig
Parse the sharing and grants blocks of a loaded config dict.
config is the plain dict returned by
:func:memory.storage.read_config. Missing/empty/malformed blocks yield an
empty :class:SharingConfig (nothing shared) — this function never raises on
a partially-formed config; unusable items are skipped.
Source code in memory/sharing/config.py
session_recipients ¶
session_recipients(sharing: SharingConfig, sessions: Iterable[Mapping[str, Any]], entry_recipients_map: Mapping[str, set[str]]) -> dict[str, set[str]]
Map each shareable session id to its recipient set.
A session is shareable to a recipient R iff at least ONE of its referenced
entries (the union of entries_created/entries_accessed/
entries_modified) is visible to R. The session unit's recipient set is
therefore the UNION over its referenced entries of their recipient sets.
This is the same per-unit model as entries: the unit is encrypted to that
union U, and the per-unit scrub then keeps in the three ref arrays only the
entry ids whose recipient set is a superset of U (so every recipient of the
session can open every id it retains). A session whose arrays scrub to empty
is omitted entirely by the builder. Sessions have no status field, so all
present sessions are considered.
Source code in memory/sharing/config.py
shareable_entries ¶
shareable_entries(sharing: SharingConfig, entries: Iterable[Mapping[str, Any]], recipient_id: str) -> list[Mapping[str, Any]]
Return the subset of entries shareable to recipient_id.
entries is the list of entry dicts (as produced by
:func:memory.storage.read_all; each must carry id and parent_id).
With an empty :class:SharingConfig this returns [] — nothing shared.
Source code in memory/sharing/config.py
skill_recipients ¶
skill_recipients(sharing: SharingConfig, skills: Iterable[Mapping[str, Any]], firm_members: Iterable[str] = ()) -> dict[str, set[str]]
Map each shareable skill (by name) to its recipient set.
Skills live OUTSIDE the entry tree, so they cannot inherit a subtree scope.
Their audience is the global sharing.skills default
(:attr:SharingConfig.skills_default) UNIONED with any per-skill
grants['skill:<name>'] override:
private(default) — nobody, unless askill:<name>grant lists them.firm— every registeredfirm_membersid.bilateral— the scope'saudienceids.
Only status == "active" skills are considered; a skill resolving to no
recipients is omitted from the result (physically dropped from the bundle).
Source code in memory/sharing/config.py
decrypt ¶
Decrypt ciphertext with private_key.
private_key may be an AGE-SECRET-KEY-... string or a pre-parsed
:class:pyrage.x25519.Identity. Raises :class:DecryptError if the
ciphertext was not encrypted to this key.
Source code in memory/sharing/crypto.py
encrypt ¶
Encrypt plaintext to every recipient in recipient_pubkeys.
Each key is an age X25519 recipient string (age1...). Any recipient
holding the matching private key can :func:decrypt the result. Returns the
binary age ciphertext.
Raises :class:ValueError if no recipients are given (encrypting to nobody
would produce an unopenable blob) or if a recipient string is malformed
(surfaced as :class:pyrage.RecipientError).
Source code in memory/sharing/crypto.py
project_for_caller ¶
project_for_caller(caller_id: str, *, registry: IdentityRegistry, output_memory_dir: Path | str, source_memory_dir: Path | str | None = None, sharing: SharingConfig | None = None, include_discarded: bool = False, allowed_transports: set[str] | None = None) -> ProjectionResult
Write a .memory-shaped PLAINTEXT store of ONLY what caller_id may see.
Reuses the shared filter+scrub collector (the same code
:func:memory.sharing.bundle.build_bundle runs), then materializes — as
PLAINTEXT, with no encryption round-trip — exactly the units the caller is a
registered recipient of, into output_memory_dir (project.md plus
entries/<id>.md, documents/<id>.md, sessions/<id>.md and
skills/<name>.md), matching :func:~memory.sharing.bundle.decrypt_bundle's
output shape so :func:memory.federation.read_repo reads it unchanged.
Parity: the produced set of units and their scrubbed content are byte-for-byte
what caller_id would obtain via build_bundle + decrypt_bundle with
that caller's key (see the module docstring). sharing defaults to the
policy parsed from <source_memory_dir>/config.yaml; source_memory_dir
defaults to the local .memory store.
A caller that is unregistered or has nothing shared to it yields an empty
projection (only an empty entries/ dir, no project.md) — matching a
decrypt that opens nothing.
allowed_transports restricts the projection to units of a given transport
tier (design decision D3). The hosted-federation broker passes {"hosted"}
so it serves ONLY hosted units — structurally, a unit whose effective
transport is offline can never be projected to a broker caller, so an
offline-only store is never served over the network. The default None
applies no transport filter (every entitled unit is projected), preserving
the pre-transport-tier behaviour and keeping the projection byte-identical to
an offline build_bundle + decrypt_bundle when the store has no
transport annotations.
This function OWNS output_memory_dir and RECONCILES it on every call,
exactly like :func:~memory.sharing.bundle.decrypt_bundle: re-projecting a
caller into a previously-populated dir evicts the *.md units the caller is
no longer entitled to (from entries/, documents/, skills/,
sessions/) and drops a stale project.md when the caller ends up with
zero units, so a revoked grant genuinely disappears on the next read. Foreign
(unowned) files are preserved, and a transient I/O fault while scanning the
output skips eviction that run (never destroying a valid cache). The result is
byte-identical to a fresh build_bundle + decrypt_bundle for the
caller's CURRENT grants even when projecting into a reused output dir.
Source code in memory/sharing/projection.py
95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 | |
create_identity ¶
create_identity(identity_id: str, *, display_name: str | None = None, github: str | None = None, path: Path | str | None = None, overwrite: bool = False) -> LocalIdentity
Generate a keypair, build a :class:LocalIdentity, and persist it.
Refuses to clobber an existing identity file unless overwrite=True — a
lost private key cannot be recovered, so overwriting is opt-in.
Source code in memory/sharing/identity.py
default_identity_path ¶
Path of this machine's private identity file: ~/.angelo/identity.yaml.
Under HOME (never the workspace) so the private key cannot be committed.
default_registry_path ¶
Default path of the shared identity registry (.memory/identities.yaml).
Anchored to the memory store so it is versioned alongside the tree. Contains public keys only, so committing it is safe. Callers may override the path.
Source code in memory/sharing/identity.py
generate_keypair ¶
generate_keypair() -> KeyPair
Generate a fresh identity keypair: an age X25519 pair + Ed25519 signing pair.
Source code in memory/sharing/identity.py
load_local_identity ¶
load_local_identity(path: Path | str | None = None) -> LocalIdentity | None
Load this machine's identity, or None if the file is absent/invalid.
The public key is derived from the private key when the file omits it, so an
identity file carrying only id + private_key still loads.
Source code in memory/sharing/identity.py
load_registry ¶
load_registry(path: Path | str | None = None) -> IdentityRegistry
Load the shared identity registry, or an empty one if absent/invalid.
Expected YAML shape::
identities:
alice:
public_key: age1...
display_name: Alice
github: alice-gh
signing_public_key: ... # optional Ed25519 signing public key (base64)
bob: age1... # shorthand: id -> public key (no signing key)
Malformed rows are skipped rather than raising, so a partially-authored registry still loads the good rows.
Source code in memory/sharing/identity.py
save_local_identity ¶
save_local_identity(identity: LocalIdentity, path: Path | str | None = None) -> Path
Write identity (including the private key) to disk, chmod 0600.
Defaults to ~/.angelo/identity.yaml. The chmod is best-effort (a no-op on
filesystems that don't support POSIX modes, e.g. some Windows setups).
Source code in memory/sharing/identity.py
save_registry ¶
save_registry(registry: IdentityRegistry, path: Path | str | None = None) -> Path
Write the identity registry (public keys only) to disk.