@joe-broadhead/messaging 0.7.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/dist/bin/messaging-mcp.js +649 -12
  3. package/dist/bin/messaging-relay.js +774 -20
  4. package/dist/bin/messaging.js +748 -15
  5. package/dist/bin/messagingd.js +785 -18
  6. package/dist/lib/modules/core/agent-tools.js +23 -4
  7. package/dist/lib/modules/core/conversation/context.js +80 -0
  8. package/dist/lib/modules/core/conversation/event.js +100 -0
  9. package/dist/lib/modules/core/conversation/ids.js +38 -0
  10. package/dist/lib/modules/core/conversation/link.js +35 -0
  11. package/dist/lib/modules/core/conversation/log.js +766 -0
  12. package/dist/lib/modules/core/conversation/ratchet.js +243 -0
  13. package/dist/lib/modules/core/conversation/seal.js +198 -0
  14. package/dist/lib/modules/core/index.js +10 -1
  15. package/dist/lib/modules/core/schema/common.js +56 -0
  16. package/dist/lib/modules/core/schema/conversation.js +292 -0
  17. package/dist/lib/modules/core/schema/errors.js +39 -0
  18. package/dist/lib/modules/core/schema/index.js +1 -0
  19. package/dist/lib/modules/core/testing.js +1 -0
  20. package/dist/lib/modules/core/version.js +1 -1
  21. package/dist/spec/messaging-v1.md +324 -6
  22. package/dist/types/core/agent-tools.d.ts +20 -2
  23. package/dist/types/core/conversation/context.d.ts +56 -0
  24. package/dist/types/core/conversation/event.d.ts +36 -0
  25. package/dist/types/core/conversation/ids.d.ts +18 -0
  26. package/dist/types/core/conversation/link.d.ts +16 -0
  27. package/dist/types/core/conversation/log.d.ts +142 -0
  28. package/dist/types/core/conversation/ratchet.d.ts +95 -0
  29. package/dist/types/core/conversation/seal.d.ts +47 -0
  30. package/dist/types/core/index.d.ts +8 -1
  31. package/dist/types/core/schema/common.d.ts +56 -0
  32. package/dist/types/core/schema/conversation.d.ts +1657 -0
  33. package/dist/types/core/schema/envelope.d.ts +6 -0
  34. package/dist/types/core/schema/errors.d.ts +38 -0
  35. package/dist/types/core/schema/index.d.ts +1 -0
  36. package/dist/types/core/schema/ipc/device.d.ts +6 -0
  37. package/dist/types/core/schema/ipc/shapes.d.ts +3 -0
  38. package/dist/types/core/schema/ipc/surfaces.d.ts +6 -0
  39. package/dist/types/core/schema/ipc.d.ts +15 -0
  40. package/dist/types/core/schema/relay.d.ts +12 -0
  41. package/dist/types/core/testing.d.ts +1 -0
  42. package/dist/types/core/version.d.ts +1 -1
  43. package/package.json +1 -1
@@ -16,8 +16,8 @@ other, exchange messages, and delegate A2A tasks, through a relay that only sees
16
16
  (`urn:messaging:binding:relay:v1`), presence and session addressing, delivery receipts, and the
17
17
  extensions `urn:messaging:ext:provenance:v1` and `urn:messaging:ext:presence:v1`.
18
18
 
19
- Not in v1: hole-punched peer-to-peer transport, federation between relays, payments, and group
20
- (multicast) encryption.
19
+ Not in v1: hole-punched peer-to-peer transport, federation between relays, and payments. Conversations
20
+ (§16) encrypt to many members with sender keys; MLS is left to a later protocol version.
21
21
 
22
22
  ## 2. Encodings
23
23
 
@@ -54,8 +54,9 @@ Not in v1: hole-punched peer-to-peer transport, federation between relays, payme
54
54
  `messaging/1/invite\n`, `messaging/1/invite-join\n`, `messaging/1/delegation\n`, `messaging/1/grant\n`,
55
55
  `messaging/1/card\n`, and for hosted realms (§15) the person hash prefix
56
56
  `messaging/1/person\n`, `messaging/1/dir-read\n`, `messaging/1/device-log\n`, `messaging/1/device-keys\n`,
57
- `messaging/1/device-link\n`, `messaging/1/epoch\n` and `messaging/1/dir-key\n`). A signature or hash for
58
- one purpose never verifies for another.
57
+ `messaging/1/device-link\n`, `messaging/1/epoch\n` and `messaging/1/dir-key\n`, and for conversations (§16) the
58
+ event signature prefix `messaging/1/conv-event\n` and the hash prefixes `messaging/1/conv-genesis\n`,
59
+ `messaging/1/dm\n` and `messaging/1/self\n`). A signature or hash for one purpose never verifies for another.
59
60
  - **Hash:** SHA-256, base64url.
60
61
 
61
62
  ## 3. Identity
@@ -2093,11 +2094,36 @@ it, in its `kv` table. The relay keeps `generation` and `min_reader` in its `met
2093
2094
  the tables Litestream adds when it replicates the database (`_litestream_*`) do not count. It opens
2094
2095
  any other store only when the store records its own generation and a `min_reader` at most its own schema. It
2095
2096
  refuses every other store and changes nothing in it, and the error says how to start over: stop the daemon or
2096
- relay, move the home (or the relay database, with its `-wal` and `-shm` files) aside, and set it up again
2097
- (`messaging-relay` logs it as `relay.store_refused {db, reason}` and exits 1). A
2097
+ relay, move the home (or the relay database, with its `-wal` and `-shm` files) aside, and set it up again. A
2098
2098
  daemon checks the store before it reads or writes anything else in its home. Nothing deletes a home or a
2099
2099
  database by itself.
2100
2100
 
2101
+ **A refused store** is machine-readable, so that a client never matches the wording. Every refusal has one of two
2102
+ **reasons**, and the remedy depends on it:
2103
+
2104
+ | Reason | The store | Remedy |
2105
+ |---|---|---|
2106
+ | `pre-reset` | records no store generation, or another generation than the reader's | Start over: move the home (or the relay database) aside and set it up again. |
2107
+ | `newer` | its `min_reader` is above the reader's schema: a newer messagingd or relay wrote it | Upgrade the daemon or relay, and keep the home or database as it is. Never move it aside for this reason. |
2108
+
2109
+ - `messagingd` exits with status **78** (`EX_CONFIG` of sysexits(3); `STORE_REFUSED_EXIT` in `@messaging/core`)
2110
+ for either reason, and prints one line on stderr, `messagingd: store_refused: <reason>: <why, and what to do>`.
2111
+ No other failure exits 78.
2112
+ - `messaging-relay` exits 78 and logs one JSON event, `relay.store_refused {db, reason, message}`: `reason` is
2113
+ `pre-reset` or `newer`, and `message` is the text.
2114
+ - A client that starts the daemon and sees it exit 78 reports the error code `store_refused` (§13), with the reason
2115
+ in a field of its own (`StoreRefusedError.reason`; `reason` in `messaging --json` errors and in agent tool
2116
+ errors). It reads the reason from the two tokens after `messagingd:` on the line that start printed
2117
+ (`storeRefusedReason` in `@messaging/core`) and never parses the text after them, which may change between
2118
+ releases. A line without a reason token gives `store_refused` without a reason. `messaging daemon start` does
2119
+ this.
2120
+ - A client offers to start over (moving the home aside) **only for `pre-reset`**. For `newer`, and for a refusal
2121
+ without a reason, it tells the person to upgrade or to read messagingd's line, and moves nothing.
2122
+ - A home moved aside is named `<home>.pre-reset-<UTC timestamp>`, the timestamp as `YYYYMMDDTHHMMSSZ` (for example
2123
+ `~/.messaging.pre-reset-20261108T171500Z`). The name carries no version, so it holds for every reset, and a
2124
+ client that denies its agents the home also denies `<home>.pre-reset-*`: the moved home keeps the old keys and
2125
+ messages. The messagingd that wrote it still opens it.
2126
+
2101
2127
  **When stores grow.**
2102
2128
 
2103
2129
  - A daemon or relay opens any store of its generation whose `min_reader` is at most its own schema. It
@@ -2319,6 +2345,10 @@ callers rely on.
2319
2345
  It answers from what this machine has verified, synchronously; a caller catches up before it
2320
2346
  verifies. `Roster` implements it: `realm` is the team id, `device` is `member`, `anchor()` is
2321
2347
  `rosterAnchor`.
2348
+ - Conversations (§16): `checkVia`, `currentLinks`, `signConvEvent`, `verifyConvEvent`, `sealConvMsg`, `openConvMsg`, `sealConvMeta`,
2349
+ `openConvMeta`, `sealKeyShares`, `openKeyShares`, `ConvLog.apply`, `ConvLog.applyExpired`, `ConvLog.relate`
2350
+ and `resolveContext` throw only `MessagingError`. `ConvLog.at(seq)` is the state a log holding only entries 1
2351
+ to `seq` has. `ReceiverChains.messageKey` hands out each message key once.
2322
2352
  - `createGrant` narrows its parent and never widens it (`forbidden`); an expired parent is `expired`.
2323
2353
  - Consent (§6.9): `signConsentGrant` refuses a grant that breaks a rule (`invalid_request`) or is
2324
2354
  over `LIMITS.grantBytes` (`too_large`); `verifyConsentGrant` checks the schema, the rules, the
@@ -2812,6 +2842,8 @@ contacts, changes devices or administers a team or channel.
2812
2842
  | binding_invalid | a device binding, a device-log entry or its authority failed verification (§15) |
2813
2843
  | jelly_unavailable | Jelly ID's key set or key history could not be fetched (§15.2); never reported as `binding_invalid` |
2814
2844
  | log_conflict | a device-log entry does not extend the log's head (§15.3); resync and retry |
2845
+ | conv_conflict | a conversation entry does not extend the log, or an event's op head is not the current op (§16.7); re-read and retry |
2846
+ | stale_epoch | a conversation message is sealed under an epoch that is not the current one, or a rekey is due (§16.7); re-seal and retry |
2815
2847
  | device_limit | the person already has `LIMITS.devicesPerPerson` current devices (§15.3) |
2816
2848
  | admission_required | `dir.bind` of an entry that is not a device binding itself with a `jelly` authority (§15.10) |
2817
2849
  | admission_invalid | `dir.bind` whose Jelly token is not live now, or not for this directory's issuer, resource and person (§15.10) |
@@ -2826,6 +2858,7 @@ contacts, changes devices or administers a team or channel.
2826
2858
  | ticket_invalid | `deployment: "jelly"`: the session ticket did not verify, is out of its lifetime, or names another session or agent (§8.11) |
2827
2859
  | daemon_unavailable | a client cannot reach the daemon |
2828
2860
  | daemon_mismatch | the daemon on the socket is another release or IPC protocol than the client, or did not say which (§8.2) |
2861
+ | store_refused | messagingd refused the home's store and changed nothing; it exits 78 (§8.8). Its `reason` field says why: `pre-reset` (no or another store generation: start over by moving the home aside) or `newer` (a `min_reader` above its schema: upgrade, keep the home); absent when messagingd named none. A client code: a client that starts the daemon reports it, never the daemon over IPC |
2829
2862
  | internal | unexpected failure (details stay in local logs) |
2830
2863
 
2831
2864
  ## 14. Versioning
@@ -3424,3 +3457,288 @@ five minutes, until it answers, so a new directory becomes ready without waiting
3424
3457
  `dir_wakes`: each Jelly host's device's armed wake ticket, its `agents` and `awaiting`, its `exp` and when it may
3425
3458
  next be presented (§7, "Wake tickets"). At start the directory rebuilds the map and the epoch log from them
3426
3459
  and refuses to start if they disagree with the last tree head.
3460
+
3461
+ ## 16. Conversations
3462
+
3463
+ A conversation is one hash-chained log of signed events that the relay sequences and members verify. Membership,
3464
+ roles, epochs and settings are a function of the log. Message bodies are sealed under per-device sender keys, so a
3465
+ post is one ciphertext whatever the number of members. The schemas are in `schema/conversation.ts`, the rules in
3466
+ `conversation/` of core, and the vectors in `docs/spec/vectors/conversation.json`. The relay frames, the daemon's
3467
+ storage and the IPC surface that carry them are not specified yet; `docs/design/conversations.md` describes them.
3468
+ Until they land, chats are as §6.8 says.
3469
+
3470
+ ### 16.1 Identifiers
3471
+
3472
+ - A **participant** (`ParticipantId`) is a person (§3, §15.1) or one agent of a person: `person/agent`, where
3473
+ `agent` is a session name (§3). Every agent belongs to one person, its **person**.
3474
+ - A conversation id (`ConvId`) is one of:
3475
+ - a **dm**: `"d_" + base32lower(SHA-256("messaging/1/dm\n" || JCS([a, b] sorted)))[0..26]`, where `a` and `b`
3476
+ are its two participants, sorted by UTF-16 code units. Both ends derive the same id. `a` may equal `b`: a
3477
+ person's note to themselves.
3478
+ - a person's **self** conversation: `"s_" + base32lower(SHA-256("messaging/1/self\n" || JCS(person)))[0..26]`.
3479
+ - a **group** or **channel**: `"c_"` and a ULID its creator chooses.
3480
+
3481
+ base32lower is RFC 4648 §6 in lower case without padding, as for `PersonId` (§15.1).
3482
+ - Kinds (`ConvKind`): `dm`, `group`, `channel` and `self`. Only a channel has a visibility (`public` or
3483
+ `private`). The history policy (`none` or `shared`) says whether a late joiner may read earlier messages.
3484
+ - Roles (`ConvRole`): `owner`, `admin`, `member` and `guest`. A participant's state is `member`, or `invited`
3485
+ until it accepts or is seated (§16.7).
3486
+
3487
+ ### 16.2 Events and the log
3488
+
3489
+ Every event has a header and a body. The header is:
3490
+
3491
+ ```text
3492
+ {v: "messaging/1", realm, cid, id, by: {person, device, agent?}, ts, opHead: {seq, hash}, seen?: {seq, hash},
3493
+ class, epoch?, idx?, wake?}
3494
+ ```
3495
+
3496
+ - `id` is a ULID the author chooses. It is unique in the conversation: the log refuses an event whose `id` it
3497
+ already holds (`duplicate`). Other events refer to an event by `id`.
3498
+ - `by` names the signing device, its person and, for an agent's event, the agent. `by.device` must be a usable
3499
+ Ed25519 key (§3).
3500
+ - `opHead` is the last op the author applied: its `seq` and `h(seq)`, or `{0, h(0)}` before any.
3501
+ - `seen` is the head the author had, for gossip (§16.4).
3502
+ - `class` is:
3503
+ - `op`: a control op in plaintext (`ConvOp`), because the relay routes and checks authority by it;
3504
+ - `msg`: a sealed `MsgBody`, with the `epoch` and the ratchet index `idx` of its key (§16.5). `wake` (at most
3505
+ `LIMITS.wakeHints` (5)) names sessions of `by.person` the event may wake; it is the only plaintext hint;
3506
+ - `keys`: sender-key shares, each sealed to one device (§16.6).
3507
+ - `sig` covers the whole event: `Ed25519(by.device, "messaging/1/conv-event\n" || JCS(event without sig))`.
3508
+ - An event is at most `LIMITS.convEventBytes` (256 KiB) of JCS (`too_large`). Every object is strict: an unknown
3509
+ member is `invalid_request`.
3510
+
3511
+ The relay stores and serves **entries**: `{seq, prev, event}`, where `seq` counts from 1 and `prev` is `h(seq − 1)`.
3512
+ The relay does not sign entries; members verify the chain (§16.4).
3513
+
3514
+ **Ops** (`ConvOp`): `create`, `add`, `accept`, `join`, `leave`, `remove`, `role`, `seat`, `settings`,
3515
+ `archive` and `rekey`.
3516
+
3517
+ - `create` states the kind, the visibility (channels), the history policy, `retainDays` (1 to
3518
+ `LIMITS.retainDaysMax` (365)), the org a public channel is open to, the participants as seats `{p, role,
3519
+ device?, respondTo?}`, the permits `post`, `invite` and `pins` (`all` or `admins`; absent is `all`), and
3520
+ `sealedMeta` (§16.5).
3521
+ - `add` names 1 to `LIMITS.convOpParticipants` (100) seats; `remove` names as many participants.
3522
+ - `seat` sets an agent participant's `device` (the device that hosts it) and `respondTo` (`self`, `members` or
3523
+ `none`), and accepts its invite. Only an agent's seat carries `device` or `respondTo`.
3524
+ - `settings` changes the visibility, history, retention or permits. `archive {on}` archives or restores.
3525
+ - `rekey {epoch, cause}` starts the next epoch; `cause` is `removed`, `device`, `added`, `settings` or
3526
+ `schedule`.
3527
+
3528
+ ### 16.3 Message bodies
3529
+
3530
+ The plaintext of a `msg` event is one `MsgBody`, at most `LIMITS.convPlaintextBytes` (64 KiB) of JCS
3531
+ (`too_large`):
3532
+
3533
+ - `line`: `text` (at most `LIMITS.lineChars` (40,000) UTF-16 code units), `format` (`plain` or `md`), and
3534
+ optionally `thread` (the root's event id), `broadcast`, `mentions` (at most `LIMITS.mentions` (50)), `files`
3535
+ (at most `LIMITS.files` (10) attachments), `parts` (up to 8 A2A data parts), `answers` (the mention it answers)
3536
+ `grants` (at most `LIMITS.agentMentions` (5) delegation grants, §6.7) and `via` (§16.10).
3537
+ - `edit {target, text, mentions?}`, `delete {target}`, `react {target, key, on}` and `pin {target, on}`.
3538
+ - `task {task}`: a task as a thread. `request {to, message, delegation}` is the root, to an agent participant;
3539
+ `status` and `artifact` carry the A2A update events (§6.2) and `cancel` and `followUp` the requester's, each
3540
+ naming the root.
3541
+ - `receipt {target, stage}`: `delivered`, `handed_to_agent` or `read`.
3542
+ - `meta {name?, topic?, purpose?}` and `policy {agentContext?, bridges?}`, which sets one or both (§16.8, §16.10).
3543
+ - `sync {sync}`: a body of the person's `self` conversation, and only there.
3544
+
3545
+ A mention is `{p}` (a participant), `{all: "channel"}` or `{all: "here"}`. An attachment names a blob, its own
3546
+ AES-256-GCM key and nonce, its SHA-256 and size (at most `LIMITS.blobBytes` (25 MiB)), a media type and a name.
3547
+
3548
+ **The self conversation.** A person's `self` conversation has one participant, the person, and history `shared`.
3549
+ Its `sync` bodies (`SyncBody`) are `read {cid, upTo, thread?}`, `unread {cid, from}`, `prefs {cid, notify,
3550
+ muteUntil?, receipts?}`, `follow`, `save`, `draft`, `joined`, `grant` (a consent grant, §6.9, verified over its
3551
+ raw value), `context {rules}` (§16.8), `localRetention {cid, days}` and `keys {cid, shares}` (at most
3552
+ `LIMITS.keyShares` (512) chain keys) and `link {link}` (§16.10). The last writer wins by `seq`.
3553
+
3554
+ ### 16.4 The hash chain and gossip
3555
+
3556
+ - `h(0) = SHA-256("messaging/1/conv-genesis\n" || JCS(cid))`.
3557
+ - `h(seq) = SHA-256(h(seq − 1) || SHA-256(JCS(event)))`, over the raw 32-byte digests.
3558
+ - A member checks `prev = h(seq − 1)` and the signature over the raw received event before it reads any field.
3559
+ - **Gossip.** A member compares an event's `seen` with its own log (`ConvLog.relate`): `ahead` past its head (it
3560
+ fetches up to `seen.seq` first), `consistent` when its `h(seen.seq)` is `seen.hash`, `diverged` otherwise. A
3561
+ divergence proves that the relay showed two members different logs; the signed event is the evidence. `seen`
3562
+ at or past the event's own `seq` is `invalid_request`.
3563
+ - **Retention.** When the relay expires a `msg` or `keys` entry it keeps the entry's `seq`, `prev` and the digest
3564
+ `SHA-256(JCS(event))`, so the chain stays verifiable (`ConvLog.applyExpired`). Ops never expire: an op head that
3565
+ names an expired entry is `conv_conflict`, so a relay that withholds an op as expired is caught by the next op.
3566
+
3567
+ ### 16.5 Sender keys
3568
+
3569
+ - **Chains.** A sender device keeps one chain per (conversation, epoch). From a chain key `ck_i`:
3570
+ - the message key `mk_i = HKDF-SHA256(ikm = ck_i, salt = empty, info = "messaging/1/conv-msg", 32 bytes)`;
3571
+ - the next chain key `ck_{i+1} = HKDF-SHA256(ikm = ck_i, salt = empty, info = "messaging/1/conv-chain", 32
3572
+ bytes)`.
3573
+
3574
+ The first chain of an epoch starts at index 0 from 32 random bytes.
3575
+ - **Sealing a body.** `body = base64url(nonce || AES-256-GCM(mk_idx, nonce, JCS(MsgBody), aad = JCS(header)))`,
3576
+ with a fresh 12-byte nonce and the 16-byte tag last; the header is the event without `body` and `sig`. The
3577
+ event's `epoch` and `idx` name the key. A message key seals one body only.
3578
+ - **Sealed meta.** `create.sealedMeta` is `JCS({name?, topic?, purpose?})` sealed the same way under the creator
3579
+ device's epoch-0 key `mk_0`, with the create event's header as AAD. The creator's first message uses index 1.
3580
+ - **Rotation.** A sender starts a new chain in the same epoch after `LIMITS.chainMessages` (500) messages on one
3581
+ chain, or `LIMITS.chainMaxMs` (7 days) after starting it. The new chain starts from fresh random bytes at the
3582
+ next index, so `(epoch, device, idx)` names every key: the share with the greatest index at or below `idx`
3583
+ derives it.
3584
+ - **Receiving.** A receiver derives forward only and uses each message key once. To open messages that arrive out
3585
+ of order it keeps at most `LIMITS.skippedKeys` (1000) derived but unused keys per (epoch, sender device),
3586
+ dropping the lowest indexes first; a message further than that past the chain is `too_large`, and one whose key
3587
+ is used or dropped is `not_found`. It keeps each share's chain key at the share's index, to share history (§16.6).
3588
+ - **Epochs.** A `rekey` op starts the next epoch. Each sender then starts a new chain and shares it only with the
3589
+ devices of the participant persons at the rekey's `seq`.
3590
+ - Authenticity comes from the event signature, not from the key: a member who holds a sender key still cannot
3591
+ write as another member. Within an epoch there is no post-compromise security: a copy of a device's state reads
3592
+ every chain it held until the next rotation or epoch.
3593
+
3594
+ ### 16.6 Key shares
3595
+
3596
+ A `keys` event's body is `{shares: [{to: {person, device}, epk, nonce, ct}]}`, one entry per recipient device
3597
+ (at most `LIMITS.keySharesPerEvent` (500); a larger share is split over several events). Each `ct` seals
3598
+ `JCS({shares: [KeyShare]})`, where a `KeyShare` is `{epoch, device, idx, ck}` (at most `LIMITS.keyShares` (512)):
3599
+
3600
+ 1. Generate an ephemeral X25519 key pair; `epk` is its raw public key, `nonce` 12 random bytes.
3601
+ 2. `shared = X25519(eph_priv, recipient.enc)`, the recipient device's key as the realm verified it (§4, §15.7).
3602
+ 3. `key = HKDF-SHA256(ikm = shared, salt = epk || recipient.enc, info = "messaging/1/seal", 32 bytes)`, as for
3603
+ an envelope (§5, "Sealing").
3604
+ 4. `ct = AES-256-GCM(key, nonce, JCS({shares}), aad = JCS({header, to}))`, with the tag last.
3605
+
3606
+ The AAD binds a share to its event and its recipient: moving it to another event or device makes it fail. A
3607
+ receiver keeps the first share for a given (epoch, device, idx); a later one with another key changes nothing.
3608
+
3609
+ ### 16.7 Authority
3610
+
3611
+ `ConvLog.apply` checks each entry, in order, against the state after every earlier entry, and a failed check
3612
+ changes nothing:
3613
+
3614
+ 1. I-JSON (§2, `invalid_request`) and size (`too_large`); the schema, the realm and the `cid` (`invalid_request`).
3615
+ 2. The chain: `seq = head + 1` and `prev = h(head)` (`conv_conflict`).
3616
+ 3. The signature over the raw event (`bad_signature`).
3617
+ 4. When appending live (the relay, or a member checking a pushed event): `ts` within `LIMITS.clockSkewMs` of now
3618
+ (`expired`), and `by.device` a current device of `by.person` in the realm (`unknown_device`).
3619
+ 5. `id` not already in the log (`duplicate`); `seen` before the event (`invalid_request`).
3620
+ 6. The first entry is `create`, and there is only one (`conv_conflict`).
3621
+ 7. For an `op`: `opHead` is the current op (`conv_conflict`); `by.agent` is absent: an agent never writes an op
3622
+ (`forbidden`). For a `msg` or `keys`: `opHead` names an op of this log, or `{0, h(0)}` (`conv_conflict`).
3623
+ 8. The rules below. An author who is not a participant is `not_member`, one who lacks the authority `forbidden`, a
3624
+ target that is not a message of this log `not_found`, and a target in the wrong state `invalid_state`.
3625
+
3626
+ A participant **person** is the person of any participant, so the devices of a person whose agent sits in a
3627
+ conversation append its keys and rekeys. "Admin" below means `owner` or `admin`; an author's role is its own
3628
+ participant's.
3629
+
3630
+ | Event | Allowed when |
3631
+ |---|---|
3632
+ | `create` | A **dm**: exactly two seats, both `member`, no visibility, org, permits or meta, history `none`, `cid` the derived one, and the author is the person of one seat. A **self**: one seat, the author as `owner`, `cid` the author's, history `shared`. A **group**: 3 to `LIMITS.groupParticipants` (50) distinct seats, no visibility or org. A **channel**: a visibility, and an org if public, at most `LIMITS.convParticipants` (1000) seats. In a group or channel the author is a seat as `owner`. |
3633
+ | `add` | A group or channel (never a dm or self). The author is a member, not a guest, and an admin when `invite` is `admins`. No seat is a current participant; only an owner adds an owner or an admin; the kind's limit holds (`too_large`). |
3634
+ | `accept` | The author's own participant is `invited`. |
3635
+ | `join` | A public channel the author is not in; live, the author's person is in the channel's org. |
3636
+ | `leave` | A group or channel; the author is a participant. Their agents leave with them. The last owner cannot leave while another person remains. |
3637
+ | `remove` | A group or channel. The author is an admin, and an admin never removes an owner; or the target is the author's own agent (whether or not the author is a participant). Removing a person removes their agents. The last owner cannot be removed. |
3638
+ | `role` | A group or channel. The author is an owner, or an admin setting `member` or `guest` on a participant who is not an owner. The last owner cannot be demoted. |
3639
+ | `seat` | The target is an agent participant whose person is the author. It becomes `member`. |
3640
+ | `settings`, `archive` | A group or channel; the author is an admin. Only a channel has a visibility, and a public one needs an org (`invalid_state`). |
3641
+ | `rekey` | The author's person is a member participant person; `epoch` is the current one plus 1 (`invalid_request`). The cause is due (`invalid_state`), or is `device`, or is `schedule` and the epoch began at least `LIMITS.rekeyScheduleMs` (30 days) before. |
3642
+ | any `msg` | The author's participant is a member; `epoch` is the current epoch and no rekey is due (`stale_epoch`). |
3643
+ | `line` | Not archived; an admin when `post` is `admins`. A guest may post. `thread` and `answers` name messages of the log. A line with `via` is a person's, never an agent's. |
3644
+ | `edit` | Not archived; the target is a line, not deleted, by the same participant (person and agent). |
3645
+ | `delete` | The target is a line or a task event, not deleted, and the author wrote it or is an admin. |
3646
+ | `react` | As `line`; the target is not deleted; at most `LIMITS.reactionsPerEvent` (50) keys per target (`too_large`). |
3647
+ | `pin` | Not archived; an admin when `pins` is `admins`; the target is not deleted; at most `LIMITS.pins` (100) pinned (`too_large`). |
3648
+ | `task` | A `request` as `line`, to a member agent participant (`not_found`). `status` and `artifact` from that agent; `cancel` and `followUp` from the requester. The root is a task request (`invalid_state`). |
3649
+ | `receipt` | The target is a message of the log. |
3650
+ | `meta` | Not in a dm; in a channel, an admin. |
3651
+ | `policy` | Not by an agent, and not in a `self` conversation. In a channel, an admin; in a dm or group, any participant person, as a vote. |
3652
+ | `sync` | Only in a `self` conversation, where no other body goes (`invalid_request`). |
3653
+ | `keys` | The author's person is a member participant person and `by.agent` is absent. Every share goes to a participant person (`forbidden`); live, to a current device of that person (`unknown_device`). |
3654
+
3655
+ **State.** A participant starts as `member`, except an agent whose person is not the author (`invited` until its
3656
+ person seats it) and a `guest` (`invited` until they accept). Live, adding a person of another org as anything but a
3657
+ guest is `forbidden`. A rekey is **due** after a removal or a leave (cause `removed`), an add to a group (`added`),
3658
+ and a change of the history policy (`settings`); a channel does not rekey on add. While a rekey is due, every `msg`
3659
+ is `stale_epoch`: no message is sealed under a key a removed device holds. A `rekey` clears every due cause.
3660
+
3661
+ `ConvLog` keeps the state after every entry: `at(seq)` is the state of a log holding only entries 1 to `seq`. Ops
3662
+ and `policy` change it; the rules that read a message body (edit, delete, reactions, pins, tasks) run only when the
3663
+ caller passes the opened body, so the relay, which cannot open bodies, applies the header rules alone.
3664
+
3665
+ ### 16.8 Agents and history as context
3666
+
3667
+ A person decides which of their agents may mine which of their conversations as context, and a conversation's
3668
+ policy decides whether members' agents may mine it at all.
3669
+
3670
+ - **Policy** (`AgentContext`): `members` (members' agents may, each under its person's rules), `seats` (only
3671
+ agents seated in it, by their seats) or `none`. The default is `members`. In a channel an admin sets it with a
3672
+ `policy` body; in a dm or group each participant person votes, and the strictest vote counts (`none` <
3673
+ `seats` < `members`). A person's vote goes when they leave.
3674
+ - **Rules** (`ContextRule`): `{agent, scope, access, lookbackDays?}`, where `agent` is a session name or `*`,
3675
+ `scope` is `{cid}` or `{kind}` with `kind` one of `own`, `dm`, `group`, `channel.private` and
3676
+ `channel.public`, and `access` is `allow`, `ask` or `off`. A person holds at most `LIMITS.contextRules` (500),
3677
+ one per (agent, scope), synced in their `self` conversation. A conversation is `own` when its participant persons
3678
+ are the person alone.
3679
+ - **Resolution** (`resolveContext`) for agent A of person P reading conversation C as context:
3680
+ 1. a `self` conversation is never mined;
3681
+ 2. C's policy `seats` or `none` forbids the read, whatever P's rules say;
3682
+ 3. else the most specific rule of P applies (a `cid` scope over a `kind` scope, then A by name over `*`), or
3683
+ with none, the default for C's scope:
3684
+
3685
+ | Scope | Access | Lookback |
3686
+ |---|---|---|
3687
+ | `own` | `allow` | the retention |
3688
+ | `channel.public` | `allow` | 90 days |
3689
+ | `channel.private` | `ask` | 30 days |
3690
+ | `group` | `ask` | 30 days |
3691
+ | `dm` | `ask` | 14 days |
3692
+
3693
+ `off` forbids, `ask` holds the read for P to answer, `allow` serves it;
3694
+ 4. the lookback is the smallest of the rule's (or default's) lookback, C's `retainDays` and P's local
3695
+ retention for C.
3696
+ - An agent seated in C reads C from its seat by its seat, not by P's rules, unless C's policy is `none`; then it
3697
+ reads only what is addressed to it and the threads that is in (`seatReads`).
3698
+ - Agents of every client follow these rules, MCP clients included; each daemon enforces them for its own person's
3699
+ agents.
3700
+
3701
+ ### 16.9 Limits
3702
+
3703
+ | Limit | Value |
3704
+ |---|---|
3705
+ | `LIMITS.convParticipants` | 1000 participants (persons and agents) in a channel |
3706
+ | `LIMITS.groupParticipants` | 50 in a group |
3707
+ | `LIMITS.convOpParticipants` | 100 per `add` or `remove` |
3708
+ | `LIMITS.convEventBytes` | 256 KiB per event |
3709
+ | `LIMITS.convPlaintextBytes` | 64 KiB per message body |
3710
+ | `LIMITS.lineChars` | 40,000 UTF-16 code units |
3711
+ | `LIMITS.mentions`, `LIMITS.files`, `LIMITS.agentMentions` | 50, 10 and 5 per line |
3712
+ | `LIMITS.blobBytes` | 25 MiB per file |
3713
+ | `LIMITS.reactionsPerEvent`, `LIMITS.pins` | 50 keys per target, 100 pins per conversation |
3714
+ | `LIMITS.retainDaysDefault`, `LIMITS.retainDaysMax` | 90 days, 365 days |
3715
+ | `LIMITS.skippedKeys`, `LIMITS.chainMessages`, `LIMITS.chainMaxMs` | 1000, 500, 7 days |
3716
+ | `LIMITS.rekeyScheduleMs` | 30 days |
3717
+ | `LIMITS.keyShares`, `LIMITS.keySharesPerEvent` | 512 chain keys per share, 500 devices per `keys` event |
3718
+ | `LIMITS.wakeHints`, `LIMITS.contextRules` | 5, 500 |
3719
+
3720
+ ### 16.10 Linked channels (reserved)
3721
+
3722
+ A person may link an outside chat account (Telegram, then Slack) as an alias of themselves, to reach their own
3723
+ agents from a phone. The fields below are part of the schema and are validated, but no daemon acts on them yet;
3724
+ `docs/design/conversations.md` §17 describes the plan.
3725
+
3726
+ - **`via {channel}`** on a `line`: the person wrote it from a linked channel, `telegram` or `slack`. It is sealed and
3727
+ signed with the event, so every member sees it. Only a person writes it (`forbidden` on an agent's line, §16.7).
3728
+ The person's own devices also check it against the person's links (`checkVia`): a link of that channel, hosted by
3729
+ the authoring device, in force. Other members cannot see the links and do not check that.
3730
+ - **`link`**, a body of the person's `self` conversation: `{id, channel, account, host, proof, authority, linkedAt,
3731
+ revokedAt?}`.
3732
+ - `account` is the channel's id for the person's account; it is never in the device log, the directory or
3733
+ presence.
3734
+ - `host` is the device that runs the bridge.
3735
+ - `proof` is `{method: "code-sas", codeDigest, sas}`: the digest of the one-time code and the six-digit code the
3736
+ person matched on both sides.
3737
+ - `authority` is a non-empty subset of `chat` and `view` (§8.11), and never more.
3738
+ - For each `id` the record written last is the one in force, unless it has `revokedAt` (`currentLinks`).
3739
+ - **`bridges`** in `policy`: who may mirror the conversation to a linked channel: `none` (the default), `admins` or
3740
+ `members`. In a channel an admin sets it. In a dm or group the strictest vote of the participant persons counts,
3741
+ and a person who has not voted counts as `none`, so bridging needs every participant person's vote.
3742
+ - **Reader ids** (`ReaderId`): a person, or `ext:<channel>:<link id>` for a link's channel provider. They are not on
3743
+ the wire; the audience guard's reader sets use them.
3744
+ - A foreign sender on a bridged channel (`via.sender`) is not part of v1: the schema refuses it.
@@ -1,6 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { TaskState as A2ATaskState } from "./schema/a2a.js";
3
- import { ErrorCode } from "./schema/errors.js";
3
+ import { ErrorCode, StoreRefusedReason } from "./schema/errors.js";
4
4
  /**
5
5
  * The agent tool contract (spec §11, "Agent tools"): the eight tools every agent-facing client
6
6
  * offers, with one input and one output schema each. The OpenCode plugin registers them as
@@ -81,6 +81,11 @@ export declare const DONE_STATES: readonly TaskStateName[];
81
81
  * starts with the code, and `remedy` is "Next: " and this sentence.
82
82
  */
83
83
  export declare const TOOL_REMEDIES: Readonly<Record<ErrorCode, string>>;
84
+ /**
85
+ * `store_refused`'s remedy by its reason (spec §8.8), which replaces `TOOL_REMEDIES.store_refused` when the reason is
86
+ * known: only `pre-reset` moves the home aside; `newer` upgrades messagingd and keeps the home.
87
+ */
88
+ export declare const STORE_REFUSED_REMEDIES: Readonly<Record<StoreRefusedReason, string>>;
84
89
  export declare const ToolErrorBody: z.ZodObject<{
85
90
  error: z.ZodObject<{
86
91
  code: z.ZodEnum<{
@@ -111,6 +116,8 @@ export declare const ToolErrorBody: z.ZodObject<{
111
116
  binding_invalid: "binding_invalid";
112
117
  jelly_unavailable: "jelly_unavailable";
113
118
  log_conflict: "log_conflict";
119
+ conv_conflict: "conv_conflict";
120
+ stale_epoch: "stale_epoch";
114
121
  device_limit: "device_limit";
115
122
  admission_required: "admission_required";
116
123
  admission_invalid: "admission_invalid";
@@ -125,10 +132,15 @@ export declare const ToolErrorBody: z.ZodObject<{
125
132
  ticket_invalid: "ticket_invalid";
126
133
  daemon_unavailable: "daemon_unavailable";
127
134
  daemon_mismatch: "daemon_mismatch";
135
+ store_refused: "store_refused";
128
136
  internal: "internal";
129
137
  }>;
130
138
  message: z.ZodString;
131
139
  remedy: z.ZodString;
140
+ reason: z.ZodOptional<z.ZodEnum<{
141
+ "pre-reset": "pre-reset";
142
+ newer: "newer";
143
+ }>>;
132
144
  }, z.core.$strip>;
133
145
  }, z.core.$strip>;
134
146
  export type ToolErrorBody = z.infer<typeof ToolErrorBody>;
@@ -136,7 +148,7 @@ export type ToolErrorBody = z.infer<typeof ToolErrorBody>;
136
148
  * The body of a tool error in Jelly's house style (§13.14): `message` starts with the code, `remedy` is
137
149
  * the code's one next step. `message` must never hold peer text, bodies or keys.
138
150
  */
139
- export declare function toolErrorBody(code: ErrorCode, message: string): ToolErrorBody;
151
+ export declare function toolErrorBody(code: ErrorCode, message: string, reason?: StoreRefusedReason): ToolErrorBody;
140
152
  export declare const ToolFiles: z.ZodArray<z.ZodObject<{
141
153
  name: z.ZodString;
142
154
  mime: z.ZodString;
@@ -465,6 +477,8 @@ export declare const AgentTool: {
465
477
  binding_invalid: "binding_invalid";
466
478
  jelly_unavailable: "jelly_unavailable";
467
479
  log_conflict: "log_conflict";
480
+ conv_conflict: "conv_conflict";
481
+ stale_epoch: "stale_epoch";
468
482
  device_limit: "device_limit";
469
483
  admission_required: "admission_required";
470
484
  admission_invalid: "admission_invalid";
@@ -479,6 +493,7 @@ export declare const AgentTool: {
479
493
  ticket_invalid: "ticket_invalid";
480
494
  daemon_unavailable: "daemon_unavailable";
481
495
  daemon_mismatch: "daemon_mismatch";
496
+ store_refused: "store_refused";
482
497
  internal: "internal";
483
498
  }>>;
484
499
  expect: z.ZodOptional<z.ZodEnum<{
@@ -727,6 +742,8 @@ export declare const AgentTool: {
727
742
  binding_invalid: "binding_invalid";
728
743
  jelly_unavailable: "jelly_unavailable";
729
744
  log_conflict: "log_conflict";
745
+ conv_conflict: "conv_conflict";
746
+ stale_epoch: "stale_epoch";
730
747
  device_limit: "device_limit";
731
748
  admission_required: "admission_required";
732
749
  admission_invalid: "admission_invalid";
@@ -741,6 +758,7 @@ export declare const AgentTool: {
741
758
  ticket_invalid: "ticket_invalid";
742
759
  daemon_unavailable: "daemon_unavailable";
743
760
  daemon_mismatch: "daemon_mismatch";
761
+ store_refused: "store_refused";
744
762
  internal: "internal";
745
763
  }>>;
746
764
  }, z.core.$strip>>>;
@@ -0,0 +1,56 @@
1
+ import type { PersonId, SessionName } from "../schema/common.js";
2
+ import { type AgentContext, type ContextAccess, type ContextKind, type ContextRule, type ConvId, type ConvKind, type Visibility } from "../schema/conversation.js";
3
+ /** The defaults that apply where a person has no rule (decided 2026-11-08). `lookbackDays` absent: the retention. */
4
+ export declare const CONTEXT_DEFAULTS: Readonly<Record<ContextKind, {
5
+ access: ContextAccess;
6
+ lookbackDays?: number;
7
+ }>>;
8
+ /** The facts about a conversation that resolution reads. `persons` are its participant persons at the read. */
9
+ export interface ContextConv {
10
+ cid: ConvId;
11
+ kind: ConvKind;
12
+ visibility?: Visibility;
13
+ persons: readonly PersonId[];
14
+ retainDays: number;
15
+ agentContext: AgentContext;
16
+ }
17
+ /**
18
+ * The scope a conversation falls in for `person`: `own` when its participant persons are `person` alone, else its
19
+ * kind (a channel by its visibility). A `self` conversation has none: no agent mines it.
20
+ */
21
+ export declare function contextKindOf(person: PersonId, conv: Pick<ContextConv, "kind" | "visibility" | "persons">): ContextKind | undefined;
22
+ /**
23
+ * The person's rule for (agent, conversation), most specific first: a `cid` scope over a `kind` scope, then a named
24
+ * agent over `*`. Undefined when no rule matches; the defaults then apply.
25
+ */
26
+ export declare function matchContextRule(rules: readonly ContextRule[], agent: SessionName, cid: ConvId, kind: ContextKind): ContextRule | undefined;
27
+ /** The outcome of a context read: served, held for the person (`ask`), or refused. */
28
+ export type ContextDecision = {
29
+ access: "allow" | "ask";
30
+ lookbackDays: number;
31
+ source: "rule" | "default";
32
+ } | {
33
+ access: "forbidden";
34
+ source: "policy" | "rule" | "self";
35
+ };
36
+ /**
37
+ * The effective rule for agent `agent` of `person` reading `conv` as context (not by its seat):
38
+ * - the conversation's policy `seats` or `none` forbids it, whatever the person's rule says;
39
+ * - else the person's rule, or the default: `off` forbids, `ask` holds, `allow` serves;
40
+ * - the lookback is the smallest of the rule's (or the default's) `lookbackDays`, the conversation's `retainDays`,
41
+ * and the person's local retention for it.
42
+ * Throws `invalid_request` for a rule set that fails `ContextRules`.
43
+ */
44
+ export declare function resolveContext(args: {
45
+ person: PersonId;
46
+ agent: SessionName;
47
+ conv: ContextConv;
48
+ rules: readonly ContextRule[];
49
+ localRetentionDays?: number;
50
+ }): ContextDecision;
51
+ /**
52
+ * What a seated agent reads by its seat (spec §16.8): everything from its seat's `seq` on, unless the policy is
53
+ * `none`, when it reads only the events addressed to it and the threads they are in. The person's rules do not
54
+ * apply to seat reads.
55
+ */
56
+ export declare function seatReads(agentContext: AgentContext): "conversation" | "items";
@@ -0,0 +1,36 @@
1
+ import type { Identity } from "../identity.js";
2
+ import { type Hash } from "../schema/common.js";
3
+ import { type ConvEvent, type ConvEventHeader, type ConvId, type UnsignedConvEvent } from "../schema/conversation.js";
4
+ /** h(0) of `cid`'s log: SHA-256(DOMAIN.convGenesis ‖ JCS(cid)), base64url. */
5
+ export declare function convGenesisHash(cid: ConvId): Hash;
6
+ /** SHA-256(JCS(event)), the raw digest, as the chain links it. Throws `invalid_request` for non-I-JSON. */
7
+ export declare function convEventDigest(event: unknown): Uint8Array;
8
+ /** h(seq) = SHA-256(h(seq − 1) ‖ SHA-256(JCS(event))), over the raw 32-byte digests, base64url. */
9
+ export declare function convEntryHash(prev: Hash, event: unknown): Hash;
10
+ /** `convEntryHash` from the event's digest: what a member checks for an entry whose content has expired. */
11
+ export declare function convLinkHash(prev: Hash, digest: Uint8Array): Hash;
12
+ /** The header of an event: everything but `body` and `sig`. JCS of it is the AAD of a sealed body. */
13
+ export declare function convEventHeader(event: ConvEvent | Omit<ConvEvent, "sig"> | ConvEventHeader): ConvEventHeader;
14
+ /**
15
+ * Checks that `raw` is I-JSON within `LIMITS.jsonDepth` and at most `LIMITS.convEventBytes` (`too_large`), that it
16
+ * matches `ConvEvent`, and that `by.device` is a usable Ed25519 key. Returns a frozen JSON copy of the raw value, so
17
+ * the signature and the hash cover exactly what was received. The signature is not checked.
18
+ */
19
+ export declare function admitConvEvent(raw: unknown): ConvEvent;
20
+ /**
21
+ * Signs an event (spec §16.4): `sig = Ed25519(by.device, DOMAIN.convEvent ‖ JCS(event without sig))`. `by.device`
22
+ * must be `identity`'s machine. Refuses an event that fails the schema (`invalid_request`) or is over
23
+ * `LIMITS.convEventBytes` once signed (`too_large`). Authority is checked when the event is applied.
24
+ */
25
+ export declare function signConvEvent<E extends UnsignedConvEvent>(identity: Identity, unsigned: E): E & {
26
+ sig: string;
27
+ };
28
+ /** True only for a valid signature by `event.by.device` over the event without `sig`. Never throws. */
29
+ export declare function convEventSigned(event: ConvEvent): boolean;
30
+ /**
31
+ * Admits `raw` (`admitConvEvent`) and checks its signature over the raw value (`bad_signature`). Membership and
32
+ * authority are `ConvLog.apply`'s. Throws only MessagingError.
33
+ */
34
+ export declare function verifyConvEvent(raw: unknown): ConvEvent;
35
+ /** Freezes a JSON value and everything in it, iteratively. */
36
+ export declare function deepFreeze<T>(value: T): T;
@@ -0,0 +1,18 @@
1
+ import { PersonId } from "../schema/common.js";
2
+ import { type ConvId, ParticipantId } from "../schema/conversation.js";
3
+ /**
4
+ * The id of the dm between `a` and `b`: `"d_" + base32lower(SHA-256(DOMAIN.dm ‖ JCS([a, b] sorted)))[0..26]`. Both
5
+ * ends derive the same id, so a dm has no create race. `a` may equal `b`: a person's note to themselves.
6
+ */
7
+ export declare function dmConvId(a: ParticipantId, b: ParticipantId): ConvId;
8
+ /** The id of `person`'s own `self` conversation: `"s_" + base32lower(SHA-256(DOMAIN.self ‖ JCS(person)))[0..26]`. */
9
+ export declare function selfConvId(person: PersonId): ConvId;
10
+ /** The person a participant is, or belongs to. */
11
+ export declare function participantPerson(p: ParticipantId): PersonId;
12
+ /** The agent's session name, for an agent participant. */
13
+ export declare function participantAgent(p: ParticipantId): string | undefined;
14
+ /** The participant an event's `by` names: `person`, or `person/agent`. */
15
+ export declare function participantOf(by: {
16
+ person: PersonId;
17
+ agent?: string | undefined;
18
+ }): ParticipantId;
@@ -0,0 +1,16 @@
1
+ import type { MachineId, PersonId } from "../schema/common.js";
2
+ import { type LinkRecord, type MsgBody, type ReaderId } from "../schema/conversation.js";
3
+ /** The links in force: for each link id the record written last (records are in `seq` order), unless revoked. */
4
+ export declare function currentLinks(records: readonly LinkRecord[]): LinkRecord[];
5
+ /**
6
+ * Checks a line's `via` against the author's links, as the author's own devices can (other members cannot see the
7
+ * person's links): only a person writes from a linked channel, never an agent, and only through a link of that
8
+ * channel that the authoring device hosts and that is in force. Throws `forbidden`.
9
+ */
10
+ export declare function checkVia(body: MsgBody, by: {
11
+ person: PersonId;
12
+ device: MachineId;
13
+ agent?: string;
14
+ }, links: readonly LinkRecord[]): void;
15
+ /** The reader id of a link's channel provider, for the audience guard: `ext:<channel>:<link id>`. */
16
+ export declare function linkReader(link: Pick<LinkRecord, "channel" | "id">): ReaderId;