TinyCloud · Sharing spec
The link is a combo:
possession and proof.
Today's share link is a bearer credential — anyone holding the URL is Bob. TinyCloud Sharing v2 keeps everything good about that (the delegation travels with the link, the recipient self-serves, the sender can be offline) and, for addressed shares — the default — replaces the embedded key with a policy proof requirement: holding the link is necessary, but you must also prove you're the person it was addressed to. "Anyone with the link" bearer shares remain a per-share option, with honestly weaker semantics.
bafkr…). Immutable — the registry can't swap bytes under a URL, and clients verify the hash before decrypting.
Inside the ciphertext: a sender-signed envelope — the full delegation chain, a signed
authorizationTarget (policy / bearer key / recipient DID), the signed
target origin and resource selector, and display metadata. The recipient's client
verifies everything before sending anything anywhere.
§1
How it works
Adam wants to send a document to Bob (bob@gmail.com), who has never used
TinyCloud. Adam's agent signs a delegation whose delegatee is a pinned policy
CID: “anyone who can present an EmailVerification credential
for bob@gmail.com may read shares/<shareId>/*.” Bob brings his
own key (an OpenKey passkey), proves his email once, and self-assembles
the authorizing material. If Bob then hands the work to his agent, he signs an
attenuated sub-delegation to the agent's DID. The result is one auditable chain:
delegation
proof (VP)
sub-delegation
Every hop is either cryptographically signed or credential-proven — scoped, expiring, and revocable at the node. Humans only ever make identity and consent decisions; no human ever sees the words DID, delegation, policy, or VP.
The delegation-target spectrum — one link shape, three targets
The envelope always carries the big delegation object; what kind of principal the
delegation targets is a per-share choice, and the same /s/<cid>#k=
link serves all of them. The fragment always carries only the AEAD key; whatever
additionally unlocks content — the policy proof requirement, the embedded
session key, or nothing beyond your own key — sits inside the ciphertext.
| Target | Envelope carries | Who can open | Use |
|---|---|---|---|
Policy CID policy:<cid> |
Delegation + canonical policy bytes | Anyone who can prove the policy's credential requirement | Email-addressed 1:1, org (*@acme.com), future groups — this spec's Bob flow |
| Embedded key | Delegation to a fresh session DID + that session's private JWK | Anyone holding the link — bearer semantics, but the key rides inside the encrypted envelope, not the URL | “Anyone with the link” shares |
| Recipient DID | Delegation to a known DID | Exactly that keyholder — no claim ceremony, just sign-in | Re-shares, device-to-device, agent-to-known-agent |
In the envelope, the target is a signed discriminated union — non-policy envelopes carry no vestigial policy fields, and the target kind itself is covered by the sender signature, so an attacker can't re-frame a policy share as a bearer one:
{ version, shareId, delegation, authorizationTarget,
target { origin, nodeAudience, spaceId, resource { kind: exact | prefix, path } },
display { senderName, filename, recipientHint }, expiry, signature }
authorizationTarget =
{ kind: "policy", policyCid, policyBytes }
| { kind: "bearerKey", sessionJwk }
| { kind: "recipientDid", did }
/s/<cid>#k can
decrypt the session key and read the content. What improved vs today's
tc1: links: the private key is no longer serialized into the visible URL
string (it's inside a CID-verified, sender-authenticated ciphertext),
URL-without-fragment observers — registries, logs, link-scanners — learn nothing, the
envelope gains versioning and a signed target origin, and the share is operationally
deletable. What did not improve: link theft = full delegated authority. That's
the honest price of “anyone with the link” — the sender UI must say so when that
target is chosen, and default to the policy target.
Per-target semantics
The claim protocol (challenge → VP → claim → session) is the policy path; the others are simpler.
| Target | Opening path | Renewal | Notification & revocation |
|---|---|---|---|
| Policy CID | Passkey → credential → challenge → claim → session | Session renewal re-evaluates the VC/status | New-root-DID notifications; per-DID and whole-share revocation |
| Embedded key | Decrypt JWK → direct-key invocation (no ceremony) | No credential to re-evaluate; extending means a new envelope | Holders are indistinguishable (one shared DID) — no per-holder anything; revoke the delegation CID, killing the whole link |
| Recipient DID | Sign in with the named key → direct-key invocation | Sender re-delegates (no claim to renew) | Notify on first use; revoke delegation or session |
Why not the alternatives
| Model | Link theft | Sender offline OK | Recipient identity | Verdict |
|---|---|---|---|---|
| Bearer (today) private key in link |
Total compromise | ✓ | None — holder is Bob | Fine for “anyone with the link”; wrong tool for “share with Bob” |
| Server escrow service re-issues to Bob's DID |
Safe | ✓ | ✓ | Collapses into policy-target anyway: either the escrow holds sub-delegation power from Adam, or the node verifies a conditional delegation — which is the policy primitive |
| Policy-target delegation (v2) | Attacker learns invitation metadata, gets no content | ✓ | ✓ cryptographic VC subject = VP holder = invoker |
Chosen. One primitive serves 1:1 shares, org policies, and future group shares |
§2
The flows
Every step in every flow is classified automatic (agents and services do it), easy (one tap, one paste, one OTP), or manual (the human must think). Budget: at most one manual step per actor per flow — always an identity or consent decision, never a plumbing task. The three we deliberately keep: Adam confirms the recipient email, Bob proves his email, Bob approves his agent.
Flow 1 — Adam shares (sender side)
Adam already has a space. His agent handles everything except two moments: confirming
the recipient (“is bob@gmail.com the right Bob?”) and sending the link over his own
channel — iMessage, email, whatever. TinyCloud never needs Bob's inbox for delivery,
only verification; the social context (“link from my friend Adam”) stays where trust
already exists. Default is copy-on-share: the agent snapshots the file into
shares/<shareId>/, so Adam keeps editing his original without leaking
work-in-progress. A first-time sender with no TinyCloud yet doesn't get inline setup —
the agent routes him through the signup site first and resumes the share afterwards
(Signup, wireframe 11).
Sequence — sender
sequenceDiagram
actor Adam
participant AgentA as Agent A
participant TC as TinyCloud Node (Adam's)
participant REG as Share Registry
Adam->>AgentA: "Share this README with Bob"
AgentA->>TC: PUT shares/<shareId>/README.md (snapshot copy)
AgentA-->>Adam: "Send to bob@gmail.com — right Bob? (read-only, 30d)"
Adam-->>AgentA: Yes
AgentA->>AgentA: Create policy (EmailVerification, email == bob@gmail.com), pin CID
AgentA->>AgentA: Sign delegation → delegatee policy:<policyCid>, scope shares/<shareId>/*
AgentA->>TC: Register delegation + policy bytes
AgentA->>AgentA: Build sender-signed envelope, encrypt with fresh 256-bit key
AgentA->>REG: PUT ciphertext
REG-->>AgentA: ciphertextCid
AgentA-->>Adam: https://share.tinycloud.xyz/s/<cid>#k=<key>
Adam->>Adam: Sends link to Bob over any channel
- The agent states recipient + scope + expiry before Adam confirms — one decision covers everything (the sender's single consent moment).
- Confirm is one tap: “Is that the right Bob?” catches the wrong-email failure mode at the cheapest possible moment.
- The link carries the envelope CID + fragment key; Adam sends it over his own channel — TinyCloud never needs Bob's inbox for delivery, only verification.
- “I'll tell you when Bob claims it” — the multi-claim guardrail surfaced as presence, not paranoia; revocation is one sentence away.
- The agent doesn't inline sender setup — it hands off to the static signup site account.tinycloud.xyz and resumes the share afterwards.
- Signup means creating the owner key via OpenKey passkey (one Face ID) — the root DID every delegation chains from. Email verification is not part of signup; it's pulled in lazily at share time.
- Hosting choice at signup: TinyCloud hosted (pre-selected, works instantly) or self-hosted — point the account at your own tinycloud-node.
- Agent-created accounts exist (CLI bootstrap) but are second-class — your account should be yours; an agent-created account can be claimed later.
- Once the account is ready, the agent resumes with the exact share-confirm moment from screen 10: recipient + scope + expiry, one tap.
Effort ledger (Adam): 1 easy step (confirm) · 1 manual-ish step (paste link to Bob) · everything else automatic
Flow 2a — Bob opens the link in a browser
The whole first-time funnel is click → passkey → OTP → content: target under 60 seconds, three interactions. Bob experiences one continuous ceremony — never “sign up for a product, then do a second thing.” No dashboard, no plan picker, no empty home screen; the share content is the onboarding destination. Behind the OTP screen the client silently runs challenge → VP → claim and lands in a DID-bound session.
Sequence — human recipient, first time
sequenceDiagram
actor Bob
participant V as Share Viewer (client)
participant REG as Share Registry
participant OK as OpenKey
participant CR as OpenCredentials
participant TC as TinyCloud Node (Adam's)
Bob->>V: Open link (#35;k fragment stays client-side)
V->>REG: GET /s/<ciphertextCid>
REG-->>V: Ciphertext
V->>V: Verify CID · decrypt · verify sender sig, policy CID, target origin
V-->>Bob: "Adam ✓ shared README.md with b•••@gmail.com"
Bob->>OK: Passkey signup → Bob's DID
V->>CR: Email verification (credentials module popup)
CR-->>Bob: OTP → bob@gmail.com
Bob->>CR: Enter code
CR-->>V: EmailVerification VC (subject = Bob's DID)
V->>TC: Claim challenge (delegation CID, Bob's DID)
TC-->>V: Nonce (bound: node, delegation, policy, DID)
V->>V: Assemble VP (nonce+domain) + signed claim invocation
V->>TC: Claim
TC->>TC: Consume nonce · chain→policy · VP/VC/trust · subject=holder=invoker
TC-->>V: DID-bound session token · notify Adam (new root DID)
V->>TC: GET shares/<shareId>/README.md (token + DID-signed invocation)
TC-->>V: Content
V-->>Bob: Rendered markdown
- Verified sender header — rendered only after the client verifies the sender-signed envelope; trust runs both ways on a phishing-shaped object.
- Invitation metadata (file name, sender) is visible pre-auth because Adam chose to send it; content never is.
- Single primary action; the masked hint (b•••@gmail.com) comes from the envelope's
display.recipientHint. - Expectation-setting before any popup: passkey → email code → ~30 seconds (mitigates passkey hesitancy).
- OpenKey passkey popup — key creation is one Face ID; copy sets expectations (“No password”).
- Email OTP — 6-digit code sent to the address in the policy; resend after 20s, magic-link fallback in the same email.
- The claim page stays dimmed behind — one continuous ceremony, not “sign up for a product, then do a second thing.”
- Behind the ✓, the client silently runs challenge → VP → claim and lands in a DID-bound session.
Return visits — and second devices
Bob clicks the same link later → passkey prompt (one Face ID, ~2 s) → fresh challenge → session → content. His email credential is durable; no OTP again. Opening the link on his phone just repeats the claim with a new DID — no re-share, Adam gets one “Bob's new device” notification.
- Known recipient: the durable email credential means no OTP — the page recognizes the returning passkey account.
- Single action: one Face ID re-runs challenge → session. Opening on a new device just repeats the claim — no re-share needed.
- Fast-path budget: ~2 seconds from click to content.
- First-claim-per-root-DID notification: Adam hears when each new device claims — no renewal spam (notification-fatigue guardrail).
- Derived agent sessions are recorded under Bob's claim and notified as authorization events, not separate claimants.
- Durable claim history: every root DID and derived agent session, visible to sender and recipient, each individually revocable.
- Revocation acts at the enforcing node against the delegation CID — per-device, per-agent, or whole share. Deleting the registry blob is never revocation.
Effort ledger (Bob, first time): 3 easy steps — tap continue, one Face ID, one OTP · zero manual decisions · return visit: 1 easy step
Flow 2b — Bob hands the link to his agent
Bob pastes the link into Agent B: “look at this and do stuff with it.” The pattern is the OAuth device-authorization grant, reshaped for delegations: the agent starts a claim, surfaces a short agent-link for the human, and the human's one browser session does signup + email proof + consent — producing a Bob-signed, attenuated sub-delegation to the agent's DID and a derived session. The node never signs on Adam's behalf; it caches verified authorization.
The link is dual-audience: humans get the landing page; agents get
machine-readable instructions (content negotiation / .well-known/tinycloud-share /
an llms.txt-style block) — what this is, how to install the CLI
(npx @tinycloud/cli), and the one command to run. Everything share-specific
stays inside the encrypted envelope, which only the link-holding agent (it has
#k) can read. The link teaches the agent; no docs-hunting.
Sequence — agent recipient (device flow)
sequenceDiagram
actor Bob
participant AgB as Agent B
participant CLI as TinyCloud CLI
participant FD as Front Door (agent-link broker)
participant AL as Approval Page (Bob's browser)
participant TC as TinyCloud Node (Adam's)
Bob->>AgB: "Look at this link" (full URL incl. #35;k)
AgB->>FD: Fetch URL (agent context)
FD-->>AgB: Machine instructions: install CLI, run claim
AgB->>CLI: npx tinycloud share claim <url>
CLI->>CLI: Verify + decrypt envelope · generate agent DID
CLI->>FD: Start agent authorization (shareId, agent DID)
FD-->>CLI: agent-link URL + user code (nonce, 15 min TTL)
AgB-->>Bob: "Open this to verify & approve (code 7GHK-PMQR)"
Bob->>AL: Open agent link
AL->>AL: Root claim as in flow 2a (passkey · email VC · challenge · VP) → Bob session
AL-->>Bob: Consent: Agent B, read-only, this share, 7d + code check
Bob->>AL: Approve
AL->>AL: Sign attenuated sub-delegation Bob → agent DID
AL->>TC: Submit sub-delegation (under Bob's valid session)
TC-->>AL: Derived agent session created
AL->>FD: Complete user code
FD-->>CLI: Approved (session ref)
CLI->>TC: GET content (agent token + agent-signed invocation)
TC-->>CLI: Content
AgB-->>Bob: Works with the content in context
- The CLI verifies the ciphertext CID, decrypts with
#k, and verifies the sender signature before contacting anything — sender identity comes from the signed envelope. - Device-flow pivot: the agent surfaces a short agent-link + user code; everything share-specific stayed inside the encrypted envelope.
- The agent blocks on human consent — 15-minute TTL on the code; no consent, no access.
- Approval yields a Bob-signed, attenuated sub-delegation to the agent DID — the node never signs on Adam's behalf.
- Content is fetched with agent token + agent-signed invocation per request; the token alone never authorizes.
- First-timers run the full ceremony on this one page — steps 1–2 (passkey, email) completed inline, step 3 is consent.
- The requesting agent is named with its host device — Bob approves a specific agent, not a blanket grant.
- Attenuated scope stated plainly: read-only · this document only · 7 days renewable (default agent sub-delegation).
- Code match is the anti-phishing binding (OAuth device-flow pattern): this browser session authorizes that agent.
- Approve produces a Bob-signed sub-delegation to the agent DID → derived, shorter-lived session linked to Bob's claim.
- Known user lands straight on consent — no step indicator, no OTP; identity row confirms who is approving.
- The code-match binding is identical to first-time — never skipped.
- Approve = one Face ID + one tap: two easy steps for the entire agent flow.
Effort ledger (Bob, agent flow, already claimed): 2 easy steps (open link + Face ID, tap Approve) · first-timer: 4 easy steps · Agent B: fully automatic
One map of every path
One link serves every recipient type; the branch is simply “who opens it.” Human branches: full ceremony ≈30s first time, ~2s returning. Agent branches always pass through device-flow approval — agent access is never silent. The delegation decides the terminal mode: read-only file, folder, or editor.
Recipient decision map
flowchart TD
L([Recipient has a share link]) --> W{Who opens it?}
W -->|Human, browser| F{Used TinyCloud before?}
W -->|Pasted to an agent| A[Agent fetches link,\ngets machine instructions]
F -->|No| S[Passkey signup + email OTP\n≈30s, one combined ceremony]
F -->|Yes| P[Passkey sign-in ≈2s]
S --> C1[Claim: challenge → VP → session]
P --> C1
C1 --> V[View content]
A --> C[CLI: decrypt envelope →\nagent-link + code]
C --> H{User already claimed?}
H -->|No| S2[Passkey + OTP + Approve\non one page]
H -->|Yes| AP[Face ID + Approve]
S2 --> D[Bob-signed sub-delegation\n→ agent DID, derived session]
AP --> D
D --> R[Agent reads content,\nworks with it in context]
V -.->|footer hint: 'paste into your agent'| A
- Sender paths: known senders share in one confirm (10); first-time senders detour to account.tinycloud.xyz signup, then the agent resumes the share (11).
- One link serves every recipient type — CID-addressed encrypted envelope in the registry, decryption key in the URL fragment.
- The link is dual-audience: humans get the landing page, agents get machine instructions — the branch is simply “who opens it.”
- Human branches: full ceremony ≈30s first time (02 → 03), ~2s returning (07).
- Agent branches always pass through device-flow approval (12 → 08/09) — agent access is never silent.
- Every new root-DID claim and agent authorization notifies the sender (13); revocation lives at the node, not the registry.
- The delegation decides the terminal mode: read-only file (04), folder (05), or editor (06).
There is no single “expires” — five clocks run per share
They must be separate signed timestamps, never collapsed into one label,
because their failure states differ — and each dead end needs its own screen, never a
silent 403. Renewal one-taps only the session clock (fresh challenge + VC
re-evaluation); an expired root delegation needs the sender to re-delegate,
so the recipient's dead-end screen offers a notify-sender tap. The sender's agent is
told when a recipient hits any dead end. (Lesson from the Listen
delegation_expired incident: silent TTLs with no renewal path burn users.)
| Clock | What it governs | When it lapses |
|---|---|---|
delegationExpiresAt |
The root grant Adam signed | “Share ended — request renewal.” Only the sender can re-delegate; one-tap notify-sender. If revoked instead while the envelope is still live: “access withdrawn.” |
envelopeDeleteAfter |
Registry retention of the encrypted envelope | Envelope gone but delegation valid: “ask the sender to re-send the link” — access itself is fine |
sessionExpiresAt |
Each claimed DID's session | “One Face ID to continue” — renewal re-runs the challenge and re-evaluates the credential |
| VC expiry / status | The EmailVerification credential itself |
Re-evaluated on session renewal; a stale or revoked credential blocks renewal |
agentGrantExpiresAt |
Derived agent sub-delegations | The agent's derived session lapses; renewable per the 7-day default (flow 2b) |
§3
Signup & account creation
A living section — everything unclear about signup accumulates here until decisions land. Recipients never see it (their account creation is folded into the claim ceremony); this is about senders.
The intended shape
- First-time senders are directed to a static signup site:
account.tinycloud.xyz. The sharing flow doesn't inline account creation for senders the way it does for recipients — the agent (or the viewer's “share something yourself” footer) hands off to the account site and resumes the share afterwards (wireframe 11). - Signup there means: create the owner key via OpenKey passkey (one Face ID; the root DID all delegations chain from), then choose where your data lives — TinyCloud-hosted (default, working immediately) or self-hosted / local (run your own tinycloud-node and point the account at it).
- Agent-created accounts are possible but second-class. An agent can bootstrap an identity non-interactively via the TinyCloud CLI when its human has no account — but the principle is: the human should own the account they use, not the agent holding it for them. The agent path exists so sharing never hard-blocks; the product should mark agent-held roots and continuously nudge toward the human claiming a proper OpenKey-rooted account.
- Email verification is not necessarily part of signup — it's pulled in lazily the first time something needs it (sending a share displays your verified identity to recipients; claiming one proves yours).
Unresolved / ambiguous — update as answered
- account.tinycloud.xyz vs OpenKey — the passkey ceremony itself, or a wrapper that embeds/redirects to OpenKey? Passkeys are domain-bound (rpID) — which domain owns the credential matters for every future sign-in surface, including the share viewer. blueprint 13.1
- Hosting choice timing & migration — chosen at signup, or default-hosted with migration later? Can a space move hosted ↔ self-hosted after creation (delegations/space IDs reference the node — do existing share links survive a migration)? blueprint 13.2
- Local hosting vs sharing availability — a share served from a laptop node is only fetchable while that laptop is up. Does share-creation warn locally-hosted senders? Is the envelope registry (always us) enough for the link to resolve gracefully to “sender's node is offline”? blueprint 13.3
- Agent-bootstrap mechanics — when the CLI creates an identity for an agent's human: a full owner key held in the agent's keystore (root authority held by software)? What are the limits (create spaces? send shares?)? blueprint 13.4
- The claim/upgrade path — how does a human later take ownership of an agent-created account: re-root or transfer? Does anything break (existing shares/delegations rooted in the agent key)? blueprint 13.5
- Email verification at signup — ever required at signup itself, or strictly lazy? (Wireframe 11 shows it at share time — consistent with lazy.) blueprint 13.6
- Plans/payment at signup — free-tier limits for shares (count, size, expiry ceiling)? When does the money conversation happen — never during a first share, ideally. blueprint 13.7
- Recipient→sender conversion — Bob already has a passkey + email credential from claiming; signup for him should be one tap that only adds a space, not a second registration. (Identity reuse — no second root for Bob — is not deferrable even if the conversion polish is.) blueprint 13.8
- Passkey loss & recovery — the passkey custodies the root everything chains from. Recovery story? Does a platform-synced passkey reproduce the same DID on a second device, and what happens when it doesn't? blueprint 13.9
- Cross-domain ceremony mechanics — the exact WebAuthn/popup/redirect choreography across account.tinycloud.xyz ↔ OpenKey ↔ share.tinycloud.xyz (which domain is the RP, what's framed vs redirected), and the signup-resume protocol (how the agent knows setup finished). blueprint 13.10
- Agent-created identity ownership — until human takeover exists, an agent-created account is agent-owned: where does its key/recovery secret live? Recommendation on the table: agent-held roots cannot create durable shares in the MVP. blueprint 13.11
- Account deletion/export, and self-host origin registration — how a self-hosted node's origin becomes a valid, CSP-allowlisted share target. blueprint 13.12
§4
The viewer
One static site at share.tinycloud.xyz, four modes, selected by what the
visitor holds. UI mode derives from the effective capabilities — never from
unsigned hints. For policy-target shares that's the intersection of the signed
delegation and the embedded policy; for bearer and recipient-DID targets there is no
policy to intersect and the delegation's capabilities stand alone. The viewer switches
on the discriminated authorizationTarget before anything else — it also
decides which ceremony (none / sign-in / full claim) precedes content.
| Visitor holds | Mode |
|---|---|
| Nothing (bare URL) | Landing — product page + paste-a-share-link box (parsed locally, never POSTed or logged) |
| Delegation over an exact path, read | Document viewer — franken-markdown-quality rendering: markdown + mermaid + syntax highlighting, self-contained, beautiful |
| Delegation over a prefix, with list | Folder browser — clickable listing, click-through to the viewer per file |
Delegation includes write (kv/put) | Editor — edit/preview split, saves write back through the recipient's session |
Mode detection (decided)
| Effective capability | UI |
|---|---|
exact + kv/get | Single-file viewer |
exact + kv/get + kv/put | Single-file editor |
prefix + kv/list | Folder browser (names only) |
prefix + kv/list + kv/get | Folder browser, readable files |
prefix + kv/list + kv/get + kv/put | Folder browser, files open in editor |
prefix + kv/get, no kv/list | No folder UI — only known exact paths retrievable |
Two envelope changes this requires: a signed, typed resource selector
{ kind: "exact" | "prefix", path } instead of inferring shape from trailing
slashes; and optional display.mode as a presentation preference only —
it may narrow (open a writable file read-only at first) but never widen; when it
disagrees with capabilities, capabilities win.
- Bare front door at share.tinycloud.xyz — the hero states the product promise in one line.
- Paste-a-link input: recipients who received a link out-of-band open it here; the
#kfragment never leaves the browser. - Three trust blurbs: end-to-end verifiable · works with your agent · nothing stored unencrypted (the registry only sees ciphertext).
- Sender acquisition path: “Share something yourself → Get started.”
- Trust header: filename, verified sender, read-only badge, expiry date, signed-in identity, and Download — always visible.
- Rendered markdown includes diagrams — mermaid blocks render as figures with a caption, not raw text.
- Code blocks render as-is; [Download] in the header fetches the original file.
- Footer hint bridges to the agent flow (2b) — recipients discover the agent path without being taught.
- Folder header: folder name + item count, same trust chrome as the single-file viewer.
- Breadcrumbs track position inside the shared subtree — the delegation is scoped to exactly this share's folder (
shares/<shareId>/*). - Clickable rows with size + type; files open in the preview pane, subfolders (notes/) drill in.
- Preview pane renders the selected file with the same renderer as the single-file viewer.
- A write delegation surfaces as [Save], “editing as bob@gmail.com,” and an autosave/version indicator (“saved · v12”).
- Conflict strip (dismissible info style): sender edits never silently clobber — “Adam updated this file 2 min ago — refresh to see latest.”
- Plain-text editing pane; every save is a DID-signed write under Bob's session.
- Live preview uses the same renderer as read-only mode, mermaid included.
§5
The registry
Envelopes live on IPFS — a single-uploader kubo node we operate (product owner
call, 2026-07-11) — behind an edge front door. The envelope is addressed by
CIDv1(raw, sha2-256(ciphertext)), the same identifier IPFS assigns the
blob, so clients verify bytes regardless of who serves them and additional mirrors are
drop-ins.
The single-uploader kubo posture
- Write path: RPC API on localhost/Unix socket only, or kubo ≥0.25
API.Authorizations(per-caller bearer secrets,AllowedPathslimited to/api/v0/block/putetc.). Upload is authenticated, rate-limited, create-only (If-None-Match: *); ~1–2 KB envelopes with a conservative hard cap. - Ingestion is atomic, not fire-and-forget: put (
ipfs block put --cid-codec=raw) → assertreturned CID == precomputed CID→ pin → persist a retention record (CID,deleteAfter, uploader) — only then is the share URL returned to the sender. - Read path:
Gateway.NoFetch=true(serve local repo only — never an open proxy),Gateway.DeserializedResponses=false(trustless-only raw block/CAR responses,/ipfs/<cid>?format=raw), so IPFS-native clients like@helia/verified-fetchwork unchanged. Edge caches get explicitCache-Controlbounded bydeleteAfter, purge-on-delete with retries, and bounded negative caching. - No network participation:
Routing.Type=none— no DHT, no announcement; content reachable only via our gateway. (Breaks IPNS; irrelevant for immutable CIDs.) - Real deletion:
ipfs pin rm+repo gcon a cron keyed to envelope expiry + grace period — the thing public IPFS/Filecoin can't offer. - HA: two kubo nodes with dual
block putbehind an LB — skip IPFS Cluster (built for millions of pins). Optional R2/CDN edge cache keyed by CID for read availability; cache entries expire no later than the envelope. The upload service assertsreturned CID == precomputed CIDon every put. - Full-CID URLs, no short aliases: an alias strips the client's expected-CID byte-substitution check; the no-mutable-names rule stands.
Ops gaps to spec before build (round-4 gap review)
- Deletion is a goal, not yet a guarantee: the GC cron needs idempotent retries, a durable expiry queue, leader election (two nodes), and metrics — “cron ran” is not “bytes gone.”
- Backups vs deletion are in direct tension. Decide one: no backups + replicated live nodes (deletion is real), or backups + bounded backup retention + tombstones honored on restore. Silent full backups quietly break the deletion promise.
- Dual-node writes need semantics: write-ack policy (both? quorum?) and replica repair for a missed put.
- Upload abuse is economic/identity-based only — blobs are encrypted, content moderation is impossible; the authenticated endpoint needs per-account quotas and anomaly controls.
- Monitoring: gateway availability, blockstore size vs retention-ledger drift, GC lag, cache-purge failures.
The front door does
- Serve the static viewer shell at
/and/s/<cid> - Serve ciphertext through the trustless gateway shape, proxied/cached at the edge
- Accept authenticated, rate-limited, create-only envelope uploads
- Answer agent discovery (content negotiation /
.well-known/tinycloud-share) - Host the agent-link device-flow broker — a separate short-lived-state service that relays one-time approval/session references
The registry never
- Sees plaintext, policies, VPs, or fragment keys
- Lists envelopes
- Evaluates policies or issues sessions
- Revokes anything — the node is the only revocation authority
- Signs on the sender's behalf
Retention — and why expiry ≠ revocation
Every envelope carries an untrusted, clamped deleteAfter sidecar; an expiry
cron unpins and garbage-collects at envelope expiry + grace period, and purges any
edge-cache copies. Object expiry is privacy hygiene, never revocation — deleting
the registry blob doesn't revoke access, and rotating an envelope must explicitly revoke
the superseded delegation. Revocation happens at the enforcing node against the
delegation CID. Claim notifications also originate at the node — only it knows a root
DID is new.
Why not Storacha as the default store
Verified from Storacha's own docs (July 2026): client.remove() only removes
content from your account listing and billing, with a 30-day minimum retention; all data
is backed into Filecoin deals that Storacha renews indefinitely. Their docs say it
plainly: “Do not use Storacha for data that may need to be permanently deleted in
the future.”
For most encrypted data, permanence is survivable via crypto-shredding — destroy the key. Not here: the AEAD key is the URL fragment — it lives in every chat log that ever carried the link and can never be destroyed. Permanent ciphertext therefore means invitation metadata (sender DID, recipient email, path, node origin) readable forever by anyone who ever held the link. That's the opposite of “share expires Aug 9.”
Where Storacha does fit (growth path): an opt-in “durable encrypted
link” for senders who explicitly want permanence, with a clear warning. The
mechanics are ready when we want them — @storacha/client, UCAN-native
upload authorization, storacha.link gateway — with one note: the uploader
must assert the returned CID equals the precomputed one, since Storacha doesn't contract
raw-CID convergence.
§6
Decision record
Locked in the 2026-07-10 Claude × Codex design dialogue, with the reasoning trail kept honest.
How these were reached: four review rounds with Codex — rounds 1–2 locked the
sharing mechanism, round 3 the viewer product and registry service, and round 4 was a
gap review whose fixes (the discriminated authorizationTarget, the honest
bearer semantics, the five expiry clocks, the ops gaps, the re-sequenced prototype
order) are reflected throughout this page rather than reproduced round by round.
Mechanism — UX blueprint §12
| # | Decision | Why |
|---|---|---|
| 1 | Policy-target delegation in/behind the link, recipient self-assembles authorization | Keeps today's self-serve shape without bearer keys; escrow collapses into the same primitive with an extra trusted party |
| 2 | Encrypted envelope in a CID-addressed registry, key in URL fragment | Solves link size + metadata privacy; registry/CDN sees only ciphertext; immutable ciphertext CID defeats byte-swapping and makes stale caches harmless |
| 3 | Cleartext email inside the envelope; no hashed matchers | Emails are low-entropy — any link holder could dictionary-test a hash anyway; hashing adds canonicalization pain for cosmetic gain |
| 4 | No synthetic signed policy→holder edge — chain terminates at policy CID, VP travels as request parameter; needs normative definition + test vectors | A policy CID can't sign; inventing a fake edge complicates UCAN semantics |
| 5 | VC.credentialSubject.id == VP.holder == invocation issuer, nonce bound to node/delegation/policy/DID | Holder equality alone lets an attacker wrap someone else's VC in a self-named VP |
| 6 | Multi-claim with guardrails (first-claim notifications, claim history, TTLs, rate limits, revocation) — not single-claim escrow | An email policy means “whoever controls this mailbox,” and multi-device/agent reuse without re-sharing is the feature |
| 7 | Claim-once → short-lived DID-bound session; token + DID-signed invocation per request, token alone never sufficient | Avoids per-read VP verification without recreating a bearer credential; node caches verified authorization, never signs for Adam |
| 8 | Sender-signed envelope covers target.origin; client verifies before contacting anything | Closes the VP-exfiltration-via-tampered-host hole in today's tc1: format |
| 9 | Registry deletion ≠ revocation; envelope rotation must explicitly revoke the old delegation CID | Content-addressed stores can't express revocation; only the enforcing node can |
| 10 | Pinned policy CIDs only in share delegations (no mutable policy names) | Mutable names let share terms change under an already-issued link |
| 11 | Envelope store is IPFS — single-uploader kubo we operate (product owner call, 2026-07-11); R2/CDN as edge cache; Storacha only as opt-in durable links | CID-native store matches the protocol; self-run keeps real deletion at expiry, which public IPFS/Filecoin can't offer |
| 12 | One link shape, three delegation targets — policy CID / embedded session key / recipient DID, selected per share; fragment always carries only the AEAD key | Unifies today's bearer links with credential-gated and DID-direct shares; links stay short in every mode |
| 13 | Envelope authorizationTarget is a signed discriminated union; per-target security claims stated honestly (bearer link = content authority); five expiry clocks kept as separate signed timestamps (round-4 gap review) | The universal “possession AND proof” thesis was true only for the policy target; collapsing clocks into one “expires” label hides five distinct failure states |
| 14 | Prototype order: bearer slice → recipient-DID → policy claim → agent device flow (round-4) | Bearer target needs no policy engine/credentials/account site — de-risks registry+envelope+viewer first, in parallel with policy-engine work |
Viewer & registry — companion spec §8
| # | Decision |
|---|---|
| 1 | UI mode derives from effective capabilities (delegation ∩ policy); envelope gains a signed {kind: exact|prefix, path} selector; display.mode is a narrowing-only hint |
| 2 | TS/React static shell; Web Crypto + shared SDK for all authorization crypto; zero CDN-served executables |
| 3 | Hostile content: AST allowlist, raw HTML off, mermaid in opaque-origin sandbox, double-sanitized SVG, scriptless preview frame |
| 4 | Writes are conditional (expectedVersion CAS at the node); conflicts surface, drafts preserved; no silent last-write-wins |
| 5 | kv/list is explicit and never implied by prefix get; node-side scope intersection; anti-probing error normalization |
| 6 | Registry v1 = single-uploader kubo we operate (product owner call 2026-07-11; supersedes the R2-first engineering recommendation) + edge front door with optional R2/CDN cache; expiry cron pin rm + repo gc at expiry+grace |
| 7 | Storacha rejected as default (indefinite Filecoin renewal + fragment-key-in-link kills crypto-shredding); offered later as opt-in durable links |
| 8 | Trustless-gateway response shape everywhere, so mirrors/caches are drop-ins and clients always verify bytes against the CID |
| 9 | Full-CID URLs; no short-alias layer |
§7
Open questions
For the product owner — from both specs, one list.
Design — UX blueprint §10
- Registry operations — who runs the front door; ciphertext retention/GC policy; abuse controls for a public put-blob endpoint. blueprint 10.1
- Protocol freeze for the policy-terminated chain — six items need normative spec + test vectors before implementation: canonical policy bytes/CID (RFC 8785 JCS), the terminal-policy chain rule, delegation-CID referencing in invocations, invocation binding fields, a fixed
EmailVerificationclaim-extraction profile, VP freshness/nonce binding. blueprint 10.2 - Policy/trust state replication — shares must fail closed when policy or trust-store state is unavailable across nodes. blueprint 10.3
- Cross-node session portability — sessions are node-local in v1 (envelope pins the target origin); fine until replicated spaces serve reads from multiple nodes. blueprint 10.4
- View-only lighter tier? — a “preview without a key” mode (OTP only, no passkey)? Current stance: no — one identity model, keep the ceremony under 30 s instead of weakening it. blueprint 10.5
- Live shares — delegating into Adam's real path (updates flow through) as an explicit v2 toggle: “share a snapshot” vs “share the living doc.” blueprint 10.6
- Sender identity bootstrap — if Adam has no email credential yet, does share-creation trigger his verification flow first? blueprint 10.7
- Multi-recipient & groups — group shares as a different policy document (
email_in: […]or group credential per TC-122), same link/registry/claim machinery. blueprint 10.8 - Agent scope defaults — 7-day renewable read-only for agent sub-delegations: right default? “Just this once” option? blueprint 10.9
- CLI trust bootstrap — pinned package name + signed instruction manifest so a malicious page can't tell an agent to
npxsomething hostile. blueprint 10.10 - Credential lifecycle —
EmailVerificationproves mailbox control at issuance; VC expiry/status cadence drives how stale a compromised-mailbox claim can be. blueprint 10.11 - Bearer-mode posture — is the embedded-key target intentionally full link-theft risk? What warning does the sender see, and is policy always the default? Do bearer opens create any node-side record at all (rate limiting needs something), given per-holder identity doesn't exist? blueprint 10.12
- Sender share-management surface — screen 13 covers claim history + revoke, but there's no outgoing-shares list, copy-link-again, or expiry-extension UX; and revoke needs to distinguish session vs DID vs whole-delegation. blueprint 10.13
- Recipient “shares received” index — promised by the claim-history design but unspecced; a synced central index is itself a metadata store (local-only? account-synced? intentionally absent?). blueprint 10.14
- Missing wireframes — bearer landing (no passkey/OTP), wrong-Bob dead end, sender's target-type choice, recipient-DID wrong-account/switch, offline-sender-node state, and the expiry/revocation dead-end screens. blueprint 10.15
Product — viewer & registry §9
- Should write capability always expose the editor, or can the sender request read-only presentation despite granting write? viewer 9.1
- Remote images/outbound links in rendered markdown: allow (with privacy/phishing cost) or v1 data-URIs only? viewer 9.2
- Envelope retention grace after share expiry: immediate, 7 days, or 30 days? viewer 9.3
- Offer the explicit “durable encrypted link” (Storacha/IPFS-backed, permanence warning) — v1.x or never? viewer 9.4
- Conflict UX: expose force-overwrite at all, or require manual reconciliation before any save? viewer 9.5
- Folder metadata: names/types only, or sizes + modification times too? viewer 9.6
- Is full-CID URL length acceptable to marketing, or is a (necessarily immutable, digest-carrying) alias protocol worth designing? viewer 9.7
What to prototype first
The delegation-target spectrum changes the sequencing: the bearer target needs no policy engine, no credentials, no account site — so the registry + envelope + viewer stack can be de-risked first, in parallel with policy-engine work. Four slices, in order:
- 1 · Bearer vertical slice — direct-key delegation → discriminated envelope → kubo upload → viewer fetch/verify/decrypt/render. Validates the entire link/registry/envelope/viewer/CLI stack with zero dependency on the policy engine or OpenKey integration. (Also: it's today's
tc1:semantics reborn with short links — immediately shippable value.) - 2 · Recipient-DID slice — adds OpenKey sign-in (and settles the RP-ID question).
- 3 · Policy slice — the claim protocol against a real node, with the interop test vectors (wrong email, wrong VC subject, untrusted issuer, modified policy bytes, replayed nonce, wrong audience…). Full claim/session/credential machinery.
- 4 · Agent-link device flow (flow 2b) — after at least one human-owned root path works.
Dependency graph: the envelope schema + shared crypto SDK underlie everything; slices 1–2 need only direct-key invocation (which the node already supports); the policy engine + trust store + OpenCredentials branch feeds slice 3 and can be built in parallel with slice 1; the account site (RP-ID, hosted-space provisioning) is needed for a complete first-time-sender product but must not block validating the core link stack against an existing account.