@loess/protocol
Everything two parts of Loess must agree on: the records, how they are signed and sealed, and the checks validate and validateHead. Storage, committers, and devices import it, so they can't disagree about which bytes a record has or which heads are valid. It has no I/O and no clock.
import { sealTransaction, validate, validateHead } from '@loess/protocol';
// A writer seals a transaction under the circle key and signs it with the write key.
const bytes =
yield * sealTransaction(transaction, { dataKey, shared: keys.write, member: identityWrite });
// Storage checks the write against the head and the circle record it holds.
const validated =
yield *
validate(circle, { head, circle: record, inbox }, { _tag: 'Write', transactions: [bytes] });
// A device checks each head it receives against the one it holds.
const held = yield * validateHead(circle, heldHead, { head: headBytes, circles: [] });Architecture gives the design, and the circle README says what each key box holds and who makes it.
The records
Every record is deterministic CBOR checked by an Effect schema. decodeCbor refuses map keys out of order, a number not in its shortest form, and a field the schema doesn't define, so one record has one encoding and its hash identifies it. recordBytes writes only the fields the schema defines.
This is Ben's like in Ann's circle, as the records hold it:
// Sealed under the circle key with Ben's signature inside, and signed outside with the write key.
const like: Transaction = {
circle: annCircle,
writer: ben, // an identity's circle id, or a key
device: benPhone,
deviceCounter: 41,
changes: [{ id: 'like:20261010-a1', field: 'like/post', value: { id: 'post:20261010-k3' } }],
};
// Sealed. It lists only transaction hashes, so what a committer did can be checked.
const commit: Commit = {
previousHash,
programHash: null,
committedAt: 1791662400000,
accepted: [likeHash],
rejected: [],
};
// Signed with the commit key and by the committer, and not sealed.
const head: Head = {
circle: annCircle,
number: 57,
commitHash,
committer: ann,
circleHash, // the current circle record
pageMap, // the sealed root `PageMapNode`, or null while the circle has no pages
inboxCounter: 118, // every transaction in the inbox up to this one is settled
keyVersion: 3,
keys: { write, box },
};A change that edits a value carries replaces, the 4-byte valueHashOf of the value it saw. valueHashOf('Buy milk') is b9b2507c.
A fact refers to a blob with a reference whose id is blob: and the hex of the sealed blob's hash, and blobHashesOf gives the hashes a transaction's changes refer to. A join request is a transaction with one change: its id is join: followed by the joiner's identity, its field is joinField (circle/join), and its value is the CBOR of a JoinRequest. That holds the identity, and the signature its write key makes over joinRequestBody, which encodes the circle with the link's key.
| Record | Sealed or signed | Holds |
|---|---|---|
Transaction | sealed in a TransactionSeal with the writer's signature, then Signed | one member's Changes |
Commit | sealed | the hashes of the transactions it accepted, and each rejected one with its Rejection |
Head | Signed with the commit key and by the committer | the commit, the circle record, the page map, inboxCounter, and the key version with its public write and box keys |
CircleRecord | Signed with the own key; its first record's hash is the circle id | history, the key version, keys (commit and own), the invite links, and the sealed CircleMembers |
CircleMembers | sealed, bound to the record's place in history and the hash of the one before | the members with their roles, the committers, and the program |
Inbox | neither; storage writes it | counter, the newest inboxCounter storage gave, the waiting transactions, and what each link key has sent |
KeyBox | sealed to a member's box key | one key version's secrets for the member's role, and each earlier version's circle and box secrets and write key |
OwnerBox | sealed to an owner's box key | the own secret |
BlobUpload | Signed with the write key | a blob's circle, hash, and length |
PageMapNode | sealed | a root with the page count, its leaves, and the hashes of the first pages, or a leaf with page hashes |
Signed | a record's bytes with the shared key's signature, and the member's |
keyBoxIdOf gives the id of a key box or owner box, which the member and any holder of the circle's box key work out the same, and storage can't. circleKeysOf gives the signers, the box sealer, and the data keys an opened KeyBox holds.
The checks
Storage runs validate on each call that changes it, and passes the head, circle record, and inbox it holds. It gives each accepted transaction's hash, the upload, or the new head with its record, and refuses with Refused and a reason:
- A write: each transaction is signed by the head's write key, or by a link key the record lists. A join request is at most 1 KiB (
largestJoinRequestLength), and a link takes at most itscountrequests, counting those already in the inbox and those earlier in the same call. A request sent again doesn't count twice. - An upload: the
BlobUploadis for this circle, and the head's write key signed it. - A commit: the head is for this circle, and its number is one more than the stored head's. Head 1 creates the circle, and its record must hash to the circle id, have an empty
history, and be signed by its own key. A head references the stored record or a new one among the objects, and a new record'shistoryis the stored one's with the stored hash added, signed by the stored own key. The record's commit key signed the head. The key version is the stored one with the same keys, or one more.inboxCounteris between the stored head's and the inbox'scounter.
validateHead is the same check of a head, run by a device against the head it holds. It takes every circle record after the held one, or every record from the first when it holds none, and accepts a head several numbers and key versions ahead, because a device can miss heads. It refuses a number that isn't higher, a record chain with a gap or that doesn't reach the head's record, and a key version that goes back or changes its keys. Neither check opens a seal, so a device checks the head's committer against the sealed CircleMembers itself.
What never changes
A deploy can't change these once stored data uses them, because only a member's device can rewrite a signed or sealed object:
- the bytes of each record, which
src/fixedBytes.test.tspins with a fixed example of each, signed with fixed Ed25519 keys; - the tag and binding order of each of
sealKinds, the tags ofsigningTags, andsecretLabels, pinned in the same test; - the algorithm names in
algorithms, and the derivations ofkeysOfSecret,dataKeyOf,keyBoxIdOf, andvalueHashOf; - the words of a recovery phrase for a seed, from
formatRecoveryPhrase, and the secrets of an identity's first record thatidentitySecretsOfderives from the seed, which give the identity's id.
A change to any of them is a format change: it needs a new tag or version, and data written before keeps its old one.
The test entry
@loess/protocol/test gives fixed keys and a new circle for tests in other packages:
signerOfSeed,sealerOfSeed, anddataKeyOfSeedgive a key from a fixed seed.newCircleFixturemakes a circle owned by one key member. It gives the signed first record; head 1 with the objects of its commit; the owner'sKeyBoxandOwnerBox; theCircleKeys; the signers; and theStoredCirclethat storage holds after head 1. Each seed gives other keys.nextRecordOfsigns the record after another, andsignedHeadOfsigns a head, so a test builds later heads and records.commitOfbuilds a later head of a fixture's circle and seals its commit. Itschangesreplace fields of the head, and itssignerreplaces the commit key, so a test can build a head that storage refuses.transactionOfseals a transaction from the owner's device, anduploadOfseals a blob and signs its upload.
Layout
The top of src/ holds the records and the tables (sealKinds, signingTags, secretLabels, and algorithms). The folders hold the code: encoding/, crypto/, seal/, signing/, circleKey/, transaction/, blob/, recoveryPhrase/, and validation/.