Channels, envelopes, wraps, claims
Four concepts carry the whole ScaiClip data model. Once these click, the rest of the product is straightforward.
Channels#
A channel is where clips live. Every clip belongs to exactly one channel.
Three kinds ship in v1:
| Kind | Members | Created | Use case |
|---|---|---|---|
personal |
One user | Auto on first WS connect | Cross-device clipboard for a single person |
directed |
Two users | Idempotent on first use | 1:1 private clipboard with a peer |
team |
ScaiKey group | Explicit channel.create |
Shared clipboard for a project or team |
Team channels ship in M3. Personal and directed are live today.
A channel carries:
- A key version (starts at 1, bumps on membership change for team channels).
- A TTL policy — a per-clip default and a per-channel maximum.
- A history depth — how many past clips are retained.
- The set of member devices that hold wraps of the channel key.
The grid never sees the channel key. Every device holds an HPKE-wrapped copy under its own KEM public key. Rotating the key means rewrapping for every current device — expensive, so we do it only on membership change.
Personal channels never rotate — the membership is one user, and that user's devices come and go by re-onboarding, not rekey.
Envelopes#
An envelope is a signed record of a clip. It's what the grid stores and fans out. Envelopes are append-only per channel — every append gets a monotone seq and a grid-assigned timestamp.
Envelope structure (canonical CBOR keys):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | |
The signature covers keys 1-14 only — grid-assigned fields (seq, grid_ts) are omitted from the signature input so grid assignment doesn't invalidate the sig.
Receivers verify the signature against the sender device's sig_pub (fetched from ScaiKey directory, cached locally).
Wraps#
A wrap is an HPKE-encrypted copy of a key. Two kinds:
- Channel-key wrap — the channel key wrapped for one device. One per
(channel, key_version, device). Installed when the device is onboarded. - One-shot wrap — a per-clip content key, wrapped for one device. One per
(clip_id, device). Only present when the envelope carriesONE_SHOT.
Wraps let the grid deliver the same envelope to every member device without knowing any of their private keys. Every device unwraps its own copy locally.
Wraps live in mod_scaiclip_key_wrap with a wrap_kind discriminator (channel | oneshot | scratch).
Onboarding a new device#
When a new device joins a channel, it doesn't have a channel-key wrap yet. The onboarding flow:
- New device calls
key.wraps.get— grid returns nothing yet, queues aWrapPendingrow. - Grid pushes
wrap.requestto every online sibling device of the same user. - First sibling to answer calls
key.wraps.putwith a fresh HPKE-wrap of the channel key targeted at the new device's KEM public key. - Grid persists the wrap, marks pending fulfilled, pushes
wraps.readyto the new device. - New device re-issues
key.wraps.getand receives the wrap.
If no sibling is online, the new device waits — wraps.ready fires when a sibling comes back.
Claims#
A claim is how a ONE_SHOT clip gets consumed exactly once.
Publishing a ONE_SHOT clip requires wraps for every current member device (E_WRAPS_INCOMPLETE if any device is missing). Once published, the ciphertext sits waiting.
The first member device to call clip.claim:
- Gets the ciphertext + its own one-shot wrap in the response.
- Wins an atomic INSERT into
mod_scaiclip_claim(unique onchannel_id, seq). - Triggers grid-side crypto-shred of the envelope's ciphertext + every sibling one-shot wrap.
- Grid fans out
clip.claimedto remaining subscribers so their UIs update.
Every subsequent claim call gets E_ALREADY_CLAIMED { claimed_by, at }.
Right primitive for: MFA codes, one-time credentials, single-use tokens, "please rotate this after you've used it" secrets. The user knows: nobody else got it, and it's gone.
What the grid holds#
For clarity — this is what the server's database and cache contain, and nothing more:
- Envelope rows. Signed, but ciphertext is opaque bytes (or empty + a MediaRef pointer).
- Wrap rows. HPKE-encrypted opaque bytes.
- Membership metadata. Which device holds a wrap for which channel.
- Presence. Last-seen timestamps per device.
- Audit rows.
scaiclip.*events for every state transition. - Redis proxy cache for
ref_origintransfers, TTL bounded (60 s default). - Scratch objects in Garage S3 — again, opaque ciphertext.
Never:
- Plaintext of any clip.
- Any channel key.
- Any device private key.
- Any decrypted MediaRef payload.
That posture is the whole point.
What's next#
- Encryption & consent — the crypto stack that makes the "blind coordinator" real.
- Scratch storage & public links — how bytes bigger than the envelope get shared.
- Server-side integrations — SCSG for ScaiWave / ScaiTerm / ScaiDrive-server / ScaiEdit-server.
- SCWP reference — every wire verb.