Iceberg¶
Angelo keeps everything you learn in two local, git-backed stores — the memory tree and the zettelkasten — plus the synapse links between them. By default all of it is private to your machine. Iceberg is the layer that lets you share a chosen slice of it with specific people, and nothing more.
The name is the mental model. Your full store is the iceberg: a large private mass sitting below the waterline. Sharing raises only a thin tip above the surface — a filtered, per-recipient-encrypted copy of exactly the units you granted. Everything else stays underwater. The waterline is a hard consent boundary: content you did not grant is never written into the shared artifact at all, so there is nothing to leak, misconfigure, or accidentally expose.

- What it shares: memory entries, documents, skills, sessions; zettelkasten notes, sources, projects, citations, editorial overlays, and dataset sidecars; and cross-store synapse edges.
- How it enforces: owner-authored grants + per-recipient encryption
(native
ageviapyrage) — access is holding a key, so there are no server ACLs to trust. - How it travels: git. Iceberg only ever hands git opaque ciphertext; git is a dumb, always-on courier.
.memory, .zettelkasten, and .synapse — stays below the waterline and is never written into the shared artifact.The one idea: the unit is the atom, and it travels as ciphertext¶
Everything shareable is a unit — one memory entry, one note, one document, one skill, one session, one synapse edge. Iceberg never shares "a store"; it shares a set of units. Publishing does three things to each unit, in this order:
- Filter — keep only the units the owner's grants actually allow this audience to see. Ungranted and private units are physically omitted — they never enter the bundle, so they cannot leak.
- Scrub — rewrite each kept unit's cross-references so it can only name things the recipient can also open. A link to an omitted entry is dropped; a note that would reveal an un-shared box name has that name removed.
- Encrypt — encrypt each unit natively to its own recipients' public keys
using
age.agewraps one file key to many recipients, so a unit shared to three people is one ciphertext any of the three (and no one else) can open.
The output is a bundle: a small cleartext manifest (structure only) plus a pile of opaque per-unit ciphertexts. Because a unit is unreadable without its key, the whole bundle can live in a single git repo — even a public one. There is no server deciding who sees what; the math does.
No hand-rolled crypto
All encryption is delegated to pyrage,
the maintained Rust age binding. Iceberg never invents a cipher, a
key-wrap, or a content-key scheme — it uses age's native multi-recipient
encryption directly.
Four pillars¶
| Pillar | Question it answers | How iceberg answers it |
|---|---|---|
| Audience scopes | Who may see a unit? | private (default) · firm · bilateral (audience: [ids]), declared in sharing/grants config and inherited down the tree/project. |
| Owner-controlled grants | Who decides? | The repo owner, via an owner-authored, committed grants manifest. A consumer may subscribe, but the owner's grants decide the yield (producer-grant, not consumer-pull). |
| Keypair identity | Who is a recipient? | A firm-wide registry (.memory/identities.yaml) maps each logical id to an age public key. Grantees are encryption recipients — the registry is the enforcement anchor, not a directory of ACLs. The private key lives only in ~/.angelo/. |
| Transport axis | How far may it travel? | git-ok vs local-only. A local-only unit is never emitted to a bundle; a sealed repo (no git remote) is the enforcement primitive, and derived outputs inherit the most restrictive transport of their inputs. |
Anatomy of a bundle¶
A bundle is deliberately boring on disk — that is the point. The manifest is cleartext but structural only: format/version, and for each unit an opaque content-addressed filename. It carries no ids, no titles, no recipient lists, no box names. Everything sensitive — including which unit this is and who can read it — lives inside the ciphertext.
bundle-repo/
manifest.json # cleartext: format, version, opaque unit paths (no ids/recipients)
project.md # minimal stub (id/name/status only) — the real project body is NOT shared
units/
3f9a…c2.age # one entry/note/… encrypted to ITS recipients
a17b…e0.age
…
recipient-index/ # (zettelkasten bundles only) firm-encrypted note→recipients map
9c00…bd.age # used for cross-producer synapse edges — see below
Why a recipient can never learn what they can't open¶
Two rules give iceberg its id-secrecy property — a recipient sees only the units they were granted, and those units reveal nothing about the rest:
- Filter before encrypt. Private/ungranted units are omitted, not
encrypted-then-hidden. A scrubber can even return an
OMIT_UNITsentinel to drop a unit that would leak once redacted (e.g. a comparison table whose every row points at un-shared notes). - Per-unit cross-link scrub. References are checked against this unit's recipient set. A memory entry shared to Alice never lists a child entry Alice can't open; a note never names a box, dataset, or citation outside Alice's grant; a session's auto-derived summary is rebuilt from only the titles of entries Alice can see.
The honest residual: body prose is shipped verbatim. If you type a secret into an entry body you do share, the recipient reads it — iceberg controls which units and references cross the boundary, not what you write inside a unit you chose to share. (See Honest limitations below.)
One bundle, many audiences¶
A single bundle can serve a whole team at once: it is one manifest over many independently-encrypted units. Alice, Bob, and Carol all fetch the same repo, but each key opens only its own slice. Nobody needs a per-person repo, and the producer publishes once.
age's native multi-recipient encryption, no duplication.The round-trip: publish → sync → decrypt → render¶
Sharing is peer-to-peer over git. The producer publishes to a bundle repo they own (separate from the private source repo); the consumer subscribes to it as a git remote and pulls. There is no central store and no trusted decryptor — every read happens client-side.
federation sync git-fetches it and decrypts only what the consumer's key opens into .angelo/federation-cache/<peer>/; the existing federation read path renders that cache in the union view — same code as a local repo. A locked-down consumer that hosts nothing still works, because it only ever pulls.What iceberg covers¶
Iceberg reaches the whole of both stores — memory has full coverage, and the zettelkasten was brought to parity with it:
| Store | Shareable units |
|---|---|
| Memory | entries, a minimal project stub, documents, skills, sessions |
| Zettelkasten | notes, source _meta, projects, citations; editorial overlays (reviews, organizations/tables, outline drafts); binary/dataset sidecars (equation PNGs, dataset files, and DVC pointers so a recipient pulls large data instead of receiving it inline) |
| Synapse | cross-store memory ↔ zk edges — both same-producer and cross-producer (below) |
Each family has its own scrubbers so a shared unit never leaks an id, title, box name, quote, or file path outside the recipient's grant.
Cross-store edges across producers¶
A synapse edge links a memory entry to a zettelkasten note, and it may only ship
to someone who can open both ends — edge_recipients = entry_recipients ∩
note_recipients. That is easy when both ends are yours. It is hard when the note
lives in someone else's repo: you can't see their grant graph, only the
filtered content they already shared with you.
Iceberg solves this with a firm-scoped recipient-index policy export. When a
zettelkasten producer publishes, it also emits a small, firm-encrypted map of
note → recipients for exactly the notes it already shares. The edge author
fetches that index during federation sync and uses it to compute the
both-ends intersection locally — fail-closed: if the peer policy is missing,
stale, or doesn't cover the note, the edge is simply not shipped.
Revoking access: a worked example¶
Sharing has a lifecycle — sometimes you need to take a grant back. angelo
memory revoke / angelo zettelkasten revoke edits the policy, rebuilds the
ciphertext without the revoked recipient, and reports honestly what it did and
did not achieve. Revocation is forward-only (a law of physics, not a bug —
you can't un-send a plaintext someone already decrypted), so the whole command
is built around never overstating the cut-off.
Take a store shared like this — Alice and Bob both see project alpha, but only
Bob sees beta:
# .zettelkasten/config.yaml
grants:
alpha: [alice, bob] # project alpha
b1/n1-alpha-core: [alice, bob]
beta: [bob] # bob only
b2/n2-beta-core: [bob]
After angelo zettelkasten publish, Alice's key opens her note
(b1/n1-alpha-core). Now revoke her from alpha:
$ angelo zettelkasten revoke --id alice --project alpha
[zk revoke] revoked 'alice' within project 'alpha' from the sharing policy
[zk revoke] grants dropped from: alpha, b1/n1-alpha-core
[zk revoke] republished bundle to …/bundle: 4 encrypted units for bob
[zk revoke] 'alice' is now CUT OFF FROM:
[zk revoke] - the current bundle and all future bundles (new ciphertext excludes them)
[zk revoke] they MAY STILL HOLD (cannot be clawed back):
[zk revoke] - anything they already decrypted and saved locally
[zk revoke] - any old clone of the bundle repo they kept (unless you --purge-history AND they re-fetch)
[zk revoke] reminders:
[zk revoke] - review + commit the source-config change (.zettelkasten/config.yaml)
[zk revoke] - if cross-store synapse edges exist, run 'angelo synapse publish'
Three things happened, in order: it edited the grants (dropped Alice from
alpha's closure only), rebuilt every unit's ciphertext against the new
recipient set (note "4 encrypted units for bob" — the audience shrank), and
printed an honest three-tier report. If you now try to decrypt the rebuilt
bundle with Alice's key, she opens nothing — while Bob's beta grant was
never touched (scoped confinement).
Closing the old-clone gap. Republishing overwrites the current files, but
git history still holds the old, readable ciphertext — an old clone could
git checkout a previous commit. Add --purge-history (with --push) to
collapse the bundle repo to a single orphan commit and force-push it, making the
superseded blobs unreachable on the remote:
$ angelo zettelkasten revoke --id alice --project alpha --purge-history --push
[zk revoke] ...
[zk revoke] 'alice' is now CUT OFF FROM:
[zk revoke] - the current bundle and all future bundles (new ciphertext excludes them)
[zk revoke] - the remote's superseded ciphertext (purged + force-pushed where a remote exists)
This is the single most destructive thing iceberg does, so before it rewrites anything it verifies — by canonical repo identity, not by path or URL spelling — that the bundle repo is genuinely separate from your source workspace, and it fails closed if it can't be sure. And the report never lies: if the purge step fails (e.g. no write access to the remote), it prints a warning and omits the "purged the remote's superseded ciphertext" line rather than claiming a cut-off it didn't achieve.
Same shape for memory, and for scoped vs. global
angelo memory revoke --id alice works identically; use --scope <node> to
confine it to a subtree (the zettelkasten equivalent is --project). A
scoped revoke honestly reports residual access — if the id is still
granted outside the revoked scope (an ancestor node, another project, or a
key shared with another project), the command says so instead of claiming a
clean cut-off. Omit the scope to revoke everywhere.
Security properties & honest limitations¶
Iceberg is deliberately explicit about what it does and does not guarantee.
Guarantees
- Id-secrecy — a recipient sees only granted units, and those units name nothing (id, title, box, quote, path) outside the grant.
- Both-ends edges — a cross-store edge ships only to those who can open both endpoints; the intersection is always computed before encryption.
- Fail-closed — missing/stale/malformed policy omits the affected edge rather than over-sharing.
- No central trust — decryption is client-side; the bundle repo (and any optional relay hub) only ever holds ciphertext.
Accepted limitations (documented in code and memory)
- Forward-only revocation. Revoking a grant stops future bundles from
including a unit (and
--purge-historycan remove the superseded ciphertext from the remote), but anyone who already decrypted it keeps their plaintext copy, and a stale cached policy can lag until the consumer re-syncs. See Revoking access above for the exact three-tier behavior. Re-keying + re-encryption is a planned future item. For content where that residual is unacceptable, use the opt-in hosted federation tier (an owner-run broker that serves grant-scoped results and never hands out a decryptable copy, so revocation bites on the next read) — see Hosted federation tier below. - Firm-scoped membership leak. The recipient index is encrypted to the whole firm, so any firm member can learn who else an already-shared note is shared with. This is the deliberate, bounded cost of cross-producer edges.
- Unverifiable peer assertions.
agehides recipient identities, so the edge author cannot cryptographically verify a peer's recipient index — a malicious producer could overstate a note's grants. Bounded (the note id is already firm-visible via the index; a producer can always leak its own content), and documented as a trust boundary. - Registry consistency precondition. Cross-producer edges assume one
byte-consistent firm
identities.yamlacross repos, so ids resolve to the same keys everywhere. - Body prose ships verbatim. Iceberg controls which units and references cross the boundary, not the free text inside a unit you chose to share.
Topology: decentralized by default¶
Each producer owns a bundle repo; each consumer subscribes to the ones it cares about and pulls on a schedule. Sharing is peer-to-peer via git hosting — nothing multi-tenant to stand up or trust. A blind-relay hub (a mirror behind one URL that also distributes the identity registry) is an optional later convenience, never required: it holds only ciphertext, so it needs no auth and does no per-viewer slicing.
Hosted federation tier¶
For content sensitive enough that forward-only revocation isn't enough, an
opt-in tier inverts the model: instead of shipping a recipient a decryptable copy
they keep forever, the recipient reads through an owner-run broker that holds
the keys, serves grant-scoped rendered results (search, tree, notes, synapse — all
computed over a per-caller scoped projection), and hands the client nothing
decryptable. Revocation becomes immediate: drop the caller from the live ACL (or
edit a grant) and the next read re-projects and fails. Write-back is owner-owned
via a git branch/merge flow, so a departing contributor's merged work stays with
the employer while they lose access to both the corpus and their additions. This
trades iceberg's zero-trust and offline properties for revocability, and is scoped
as an additional tier alongside the offline default — not a replacement. It
coexists with offline sharing at the per-unit grain: a unit carries a
transport: offline | hosted tier, so the same store can ship some units as
bundles and serve others only through the broker.
The design (auth, projection, signing, write-back, and the accepted trust
boundaries) lives in documents/260708_iceberg-revocation-and-binding.md.
Running a broker¶
The broker is a FastAPI app run under uvicorn. From the owner's checkout:
angelo iceberg serve # binds 127.0.0.1:8779, read-only
angelo iceberg serve --port 9000
angelo iceberg serve --enable-writeback # also mount /memory/contribute + /zk/contribute
angelo iceberg serve --rate-limit # shed load per-IP (pre-auth) / per-caller (reads)
Everything is fail-closed by default: it binds loopback only
(127.0.0.1), write-back and rate limiting are off, and
X-Forwarded-For is not trusted. Flags only ever turn a feature on.
Configuration can also come from ICEBERG_BROKER_* environment variables
(flags win over env); the ones you are most likely to set:
| Variable / flag | Default | What it controls |
|---|---|---|
--host / ICEBERG_BROKER_HOST |
127.0.0.1 |
Bind interface. Keep it loopback and put a TLS proxy in front (below). |
--port / ICEBERG_BROKER_PORT |
8779 |
Bind port. |
--registry-path / ICEBERG_BROKER_REGISTRY_PATH |
repo .memory/identities.yaml |
The firm registry of caller public keys — the auth anchor. |
--memory-source / ICEBERG_BROKER_MEMORY_SOURCE |
process-local .memory |
Source memory store to project from. |
--zk-store-root / ICEBERG_BROKER_ZK_STORE_ROOT |
process-local .zettelkasten |
Source zk store to project from. |
--cache-root / ICEBERG_BROKER_CACHE_ROOT |
.angelo/iceberg-cache |
Where per-caller projections are materialized. |
--enable-writeback / ICEBERG_BROKER_ENABLE_WRITEBACK |
off | Mount the contributor write-back routes. |
--rate-limit / ICEBERG_BROKER_RATE_LIMIT_ENABLED |
off | Per-request load shedding (429 before expensive work). |
--trust-forwarded-for / ICEBERG_BROKER_TRUST_FORWARDED_FOR |
off | Read the client IP from X-Forwarded-For — only behind a trusted proxy. |
ICEBERG_BROKER_TRUSTED_PROXY_HOPS |
1 |
Number of trusted proxies; the real client is the N-th from the right of the header. |
An unauthenticated liveness probe is always available:
$ curl -s http://127.0.0.1:8779/api/health
{"status":"ok","service":"iceberg-broker","writeback_enabled":false,"rate_limit_enabled":false,"pid":12345}
Exposing it beyond loopback (TLS / reverse-proxy runbook)¶
The broker deliberately does not terminate TLS itself — it binds loopback and expects a reverse proxy (nginx, Caddy, a cloud load balancer) to terminate HTTPS and forward to it. The recommended shape:
- Keep the broker on loopback. Leave
--host 127.0.0.1(or bind a private interface the proxy can reach). Never expose the raw broker port publicly. - Terminate TLS at the proxy and proxy to
http://127.0.0.1:8779. With Caddy that is a two-line site block; with nginx aproxy_passto the loopback upstream inside aserver { listen 443 ssl; }. - Turn on rate limiting (
--rate-limit) once you are reachable from more than one client, so an unauthenticated flood is shed with a429before the broker does any crypto or projection work. - Only then turn on
--trust-forwarded-for, and only if the proxy setsX-Forwarded-For. SetICEBERG_BROKER_TRUSTED_PROXY_HOPSto the exact number of proxies you run so the broker reads the real client IP from the right of the chain. This pairing matters: without a trusted proxy, a client could forgeX-Forwarded-Forto dodge (or frame another IP for) the per-IP limit — so the default is to ignore the header entirely and key off the socket IP.
--trust-forwarded-for is only safe behind a proxy you run
Enable it exclusively when every request reaches the broker through a
reverse proxy that overwrites/appends X-Forwarded-For. On a directly-exposed
broker, leave it off (the default) — the socket IP is the only trustworthy
client identity there, and trusting a client-settable header would let a
single client evade the rate limiter.
The client half is iceberg.client.IcebergClient (or drive the routes directly
over HTTPS). It runs the age-nonce handshake, caches the bearer token, and
verifies TLS by default:
from iceberg.client import IcebergClient
with IcebergClient("https://broker.example.com") as ic: # uses ~/.angelo/identity.yaml
ic.health()
hits = ic.memory_search("liquidity")
note = ic.zk_note("b_main", "n1-alpha-core")
# when the broker was started with --enable-writeback:
ic.contribute_memory("e-new", {"id": "e-new", "title": "…", "body": "…"})
Enabling it¶
Iceberg ships with angelo; there is nothing extra to install (encryption uses
pyrage, pulled in with the core package).
1. Create this machine's identity (private key stays in ~/.angelo/; the
public key can be registered for others to encrypt to):
2. Declare grants in config (sharing/grants blocks in .memory/config.yaml,
.zettelkasten/config.yaml, .synapse/config.yaml) and point at a
producer-owned bundle repo (sharing.bundle_repo).
3. Publish a filtered, per-recipient-encrypted bundle:
angelo memory publish --push # memory entries/docs/skills/sessions
angelo zettelkasten publish --push # notes/sources/projects/citations/editorial/sidecars (+ recipient-index)
angelo synapse publish --push # cross-store edges
4. Subscribe & sync on the consumer (git-fetch + decrypt into the federation cache):
The decrypted cache under .angelo/federation-cache/<peer>/ then renders in the
memory/zettelkasten union views exactly like a local repo.
Run federation sync before synapse publish
Cross-producer edges reflect each peer's recipient policy as of your last
sync. Sync first so revocations and new grants are current; synapse
publish will warn (never fail) if a peer policy looks stale.
Commands¶
Natural-language phrasings that drive iceberg — say the thing, the agent runs it. See the full command cheatsheet.
| Say this | What runs |
|---|---|
| "set up my sharing identity", "make my key" | angelo memory identity init |
| "publish my memory bundle", "share these entries" | angelo memory publish |
| "publish the zettelkasten bundle", "share these notes" | angelo zettelkasten publish |
| "publish the cross-store links", "share the synapse edges" | angelo synapse publish |
| "sync the shared bundles", "pull the latest shared knowledge" | angelo federation sync |
| "revoke alice", "cut off alice from these notes" | angelo memory revoke / angelo zettelkasten revoke |
| "revoke alice and purge the old ciphertext" | angelo … revoke --purge-history --push |
| "run the hosted broker", "serve the federation tier" | angelo iceberg serve |
| "review contributions", "merge alice's work back" | angelo iceberg contributions / review / merge |
Design notes¶
The decisions behind this, drawn as a slice of the memory tree.
- D Pivot to encryption-first enforcement (drop git-ACL stage)active
Reversed the earlier 'graduated enforcement' decision. - D Phase 1 slice: encryption-first per-recipient memory bundle sharingactive
Implemented the Phase 1 vertical slice for owner-granted, per-recipient-encrypted memory bundle sharing: a full publish -> sync -> decrypt -> render round-trip for single/multi recipient using age. - D Publish target = producer-owned bundle repo; no central store (hub optional)active
Decided the transport concretely: the publish target is a PRODUCER-OWNED bundle repo, separate from the private source repo. - D Phase 2: bundle sharing extended to documents/skills/sessionsactive
Extended the encryption-first per-recipient memory bundle from entries+project to also cover documents, skills, and sessions, rendered via federation.read_repo. - D Zettelkasten encryption-first bundle sharing (v1)active
Brought encryption-first bundle sharing to the committed.zettelkasten/store via a newzettelkasten/sharing/package mirroringmemory/sharing/, reusingmemory.sharingcrypto+identity (one firm-wide age registry;… - D ZK sharing v2 Phase C shipped: cross-store synapse-edge bundle (same-producer slice)active
- Added a THIRD encryption-first per-recipient bundle in a new package
zettelkasten/synapse/sharing/for cross-store synapse edges (.synapse/links/links.json— bipartite memory<->zk edges). - D Cross-producer synapse edges via firm-scoped recipient-index policy exportactive
Enable synapse edges whose ZK endpoint is FEDERATED (zk_source contains ':') — previously omitted because edge author repo A cannot compute note_recipients for peer B's note. - N Accepted limitations of encryption-first bundle sharing
Two limitations were explicitly accepted (not bugs): - N Accepted trust boundary: recipient-index is an unverifiable peer assertion
security-review Medium, no cheap fix.
- R Angelo
- P Experiment Infrastructure: Memory Hardening + DVC Artifactsactive
- P Phase 3: Cross-Repo Federation + Unified Visualizationactive
- P Firm-wide memory + zk sharing: owner-controlled, consent-first, angelo-native layer on gitactive
- D Pivot to encryption-first enforcement (drop git-ACL stage)active
- D Publish target = producer-owned bundle repo; no central store (hub optional)active
- D Phase 1 slice: encryption-first per-recipient memory bundle sharingactive
- E 3-cycle checker hardening of the sharing bundle (high rigor)active
- N Accepted limitations of encryption-first bundle sharing
- A Use pyrage (Rust age binding) for bundle encryption; no hand-rolled crypto
- C Phase 1 sharing slice complete: publish->sync->decrypt->render round-trip green
- P Phase 2: extend memory bundle sharing to documents/skills/sessionsactive
- D Phase 2: bundle sharing extended to documents/skills/sessionsactive
- E High-rigor checker cycle: session free-text leak caught + fixedactive
- N Accepted limitations + follow-ups for docs/skills/sessions sharing
- C Phase 2 sharing extension complete: docs/skills/sessions round-trip green
- N Resolved Phase 2 nits: docstring wording + case-insensitive session blanking
- D Phase 2: bundle sharing extended to documents/skills/sessionsactive
- P Phase 3: Zettelkasten sharing v1 (note/source/project/citation)active
- D Zettelkasten encryption-first bundle sharing (v1)active
- E Checker-driven hardening: 3 fix cycles closing id/box-name leaksactive
- A Invariants for zettelkasten/sharing (preserve when editing)
- C ZK sharing v1: reviewer/security/tester PASS, critic P2-only; not yet committed
- P ZK sharing v2 Phase C: synapse edge sharing (same-producer slice)active
- D ZK sharing v2 Phase C shipped: cross-store synapse-edge bundle (same-producer slice)active
- N Phase C accepted P2s (no fix cycle) + follow-ups
- C ZK sharing COMPLETE — full-store parity with memory (A+B+C done)
- P Phase D: cross-producer synapse edges via firm-scoped recipient-index policy exportactive
- D Cross-producer synapse edges via firm-scoped recipient-index policy exportactive
- N Accepted trust boundary: recipient-index is an unverifiable peer assertion
- N Open user decision: forward-only revocation vs fail-closed staleness TTL
- C Cross-producer synapse sharing shipped (PASS)
- N P2 hardening: guard stale-policy diagnostic + truncated-config retain
- N Iceberg user-facing docs page published + design docs updated
- D Cross-producer synapse edges via firm-scoped recipient-index policy exportactive
- D ZK sharing v2 Phase C shipped: cross-store synapse-edge bundle (same-producer slice)active
- D Zettelkasten encryption-first bundle sharing (v1)active
- P Firm-wide memory + zk sharing: owner-controlled, consent-first, angelo-native layer on gitactive
- P Phase 3: Cross-Repo Federation + Unified Visualizationactive
- P Experiment Infrastructure: Memory Hardening + DVC Artifactsactive
Related¶
Semantically related entries from the memory graph.