06 — Nodes, sessions and apps
This part covers what a node must do to act for an account: the session note it writes under, and what it keeps current for the account. Then the exchanges that let a program act without the seed: connecting an app to an account home, connecting by code a program that has no browser, and carriers that keep spaces online without reading them. Hosts, carriers many accounts pay for, are in 08 — Hosting. Calls, built only from live messages, are a layer on it and described with the library (calls).
The node's programming interface is not protocol, and another implementation may shape its own however it likes. The reference one is described in the package docs: the node (creating one, stores, holding spaces, following the account, events, the app-side client, carriers and hosting), sign-in (including the account home's side of §2), agents (the library's and the CLI's use of §3), and actions (the CLI, MCP and WebMCP tools).
Material specified elsewhere is linked, not repeated:
| Topic | Where |
|---|---|
| Seeds, recovery codes, DIDs, the account vault and its wraps, notes, device keys, contact and member keys, phone pairing and the channel it runs on | 01 — Identity |
Records, versions, seq, rules, topics and tags | 02 — Records |
Spaces, roles and grants, the access log (sys.role, sys.assign, sys.remove, sys.invite, sys.revoke, sys.collection, sys.key, sys.relays, sys.keepers, sys.fork, sys.stepdown, sys.succeed), the constitution, invites, encryption, the account registry (sys.joined, sys.profile, sys.carrier), profiles | 03 — Spaces |
Relays, rooms, peer authentication, the network message envelope, the who note exchange, live messages and their limits, TURN | 04 — Network |
| Negentropy, stores, storage adapters, data folders, mirrors, what a node that holds part of a space says it holds | 05 — Sync and storage |
| Names, the inbox, the relay mailbox | 07 — Inbox |
Terms used here:
- Account — an identity derived from a seed; its DID is the root of every record it writes (01).
- Note — a delegation from an account (or a key holding one) to another key, in the UCAN 1.0 profile of 01 §7. A session note is the note a node's session key writes under.
- Session key — the P-256 key a node signs records with and proves on the
wire. Its DID is the node's
sessionDid. - Account home — a page that holds an account's seed and grants notes to apps. It is a sign-in page (the reference one: sign-in) plus the home side of §2.
- Carrier — a node that keeps spaces online without being able to read or write them. A host is a carrier for many accounts, which they pay for.
1. The node
1.1 What a node is
A node acts for one account and signs with one session key at a time. It holds a set of spaces, each with its own store and its own peers.
A node MAY hold the account key (01 §11.1). A node that is not one of the account's own devices (an app's, or any other program's) is never given the local key. With it the node follows the account registry — the account's list of spaces, its name, its carriers, hosts and own automations — and joins and leaves spaces as the account does on any device (§1.3). Without it, the node holds only the spaces it was given or joined itself.
A node MAY hold the contact key (deriveContactKeyBytes(seed)). With it the
node opens knocks left for the account in shared spaces (03 §16),
derives its inbox (07), and publishes the contact key's
public half on the account's profile in every space it writes in.
Source: packages/core/src/node/node.ts, packages/core/src/node/types.ts. Tests: packages/core/tests/node.test.ts.
1.2 The session note, and renewing it
The root key never signs records. The node asks its signer for one note that lets the session key do everything the account can, everywhere:
{ "aud": "<sessionDid>", "sub": "<account DID>", "cmd": "/", "pol": [], "exp": <now + ttl>, … }(The token and how peers verify it are in 01 §7.)
Every record this node signs carries the current note as its proof, one
token, and every live connection opens with it (the who message,
04).
The node asks for a new note before the one it holds runs out. A note's time
bounds only live checks: knocks, live messages, a peer's proof when it
connects (01 §7.6). Records are judged with no time at all,
so what a key signed counts until the key is revoked (02
§9.3). Signing out writes a revoke of the session key in every space the node writes in,
its carry spaces, the contacts space and the account registry, with a cutoff
at what it wrote, and a device that kept the key forgets it (revokeSelf).
The revokes reach other peers as this device next syncs: one that never goes
online again never delivers them. An app's note from an account home is never
renewed in place (§2.9).
Rationale: one root signature an hour, never one per write. The root — a seed in a page, an account home, anything — can stay out of reach of the code that writes.
When and how the reference node renews is in the node.
Source: packages/core/src/node/node.ts (SESSION_COMMAND, scheduleRenewal). Tests: packages/core/tests/node.test.ts ("the delegation is renewed before it expires", "records outlive the session that wrote them").
1.3 The spaces a node keeps
The account's list of spaces is the sys.joined records in its account
registry, one per space, keyed space:<id> (03). Only
sys.joined records that verify and whose root is the account itself count.
A record whose space key changed is rewritten with an invite carrying the key
in use now, so a new device joins with it.
A node holding the account key follows that list, and keeps what carriers
depend on current: each live carrier's passes and carry:closed (§4.2), and
the account's carriers named as keepers of the spaces it manages (§4.2). A
carrier sees only what these devices write, so without them it carries stale
passes or none.
The account registry, the contacts space and carry spaces are the account's
own machinery. In them a record counts only under a note that grants
/account/write (01 §8), and a node always holds them whole
(05 §5). A node whose note lacks it, such as one
granted records only, never writes the account registry: no sys.joined, no
passes, no name, no hosting receipts (every peer would ignore them).
When a node joins, leaves and reconciles, which spaces it hides from its own list, and when it publishes the account's profile are the node's own; the reference node is in the node.
Source: packages/core/src/node/node.ts (reconcileOnce), packages/core/src/node/node-carriers.ts (syncPasses, nameKeepers), packages/core/src/space/account-registry.ts. Tests: packages/core/tests/node.test.ts ("the account registry"), packages/core/tests/records-only.test.ts ("it never writes the account itself…", "in the account's own spaces, a version under a records-only note does not stand").
2. The account home protocol
An app acts for an account without ever holding its seed. It gets a note from the account to a key of its own, signed at the person's account home.
app page account home (popup)
│ opens the home in a popup │
│ ◀──────────── { type: 'minca:hello' } ──────│ posted to '*'
│── { type: 'minca:request', request } ─────▶ │ to the home's origin
│ │ person unlocks, approves
│ ◀──── { type: 'minca:grant', grant } ───────│ to the app's origin only
│ or { type: 'minca:denied', reason } │ then closes2.1 The app's key
An app MUST make its own P-256 key and keep the private half non-extractable. The key's DID is the grant's audience, and the app's node signs with this key, not a fresh one. An app that acts for more than one account MUST use a different key for each: one key in two accounts' spaces would tell their peers the accounts are one person's. The home chooses the account, not the app, so an app MUST NOT ask with a key that holds one account's note and then use it for another; asking with a new key each time is enough. Where the reference keeps it: the app-side client.
Every tab or window of one app acting for one account signs with that account's key, so they write the same lines (02 §4.10): each place in them is taken under a lock every tab shares, and read from the storage they share. A tab still open on a key the app has since replaced goes on signing with it, its own lines, until it starts again.
Known defect: so every tab or window of one app is the same DID in a room, and a peer holds one connection per DID: one tab connects to each peer, and the others don't sync with that peer until it closes. The fix is a key per tab, each with its own one-link note signed by the account through the home: the app's key can't pass its note on (01 §7.5). See #162.
Source: packages/core/src/session/connect.ts (appKey, grantStore), packages/core/src/session/connection.ts (createMincaConnection). Tests: packages/core/tests/connect.test.ts ("an app connected to several accounts").
2.2 The home's address
A home is found by its bare domain: an app given only an origin opens
<origin>/connect, so a home serves its connect page there. Example:
minca.example.com → https://minca.example.com/connect. How the reference
client reads what a person typed is in
the app-side client.
2.3 The exchange
The app:
- MUST open the home in a popup from a user gesture, before awaiting anything.
- Listens for
messageevents from the moment the popup opens, and MUST ignore any whosesourceis not the popup it opened or whoseoriginis not the home's origin. - Posts
{ type: "minca:request", request }to the popup withtargetOrigin= the home's origin on each{ type: "minca:hello" }once the request is ready, and MAY post it again at intervals until the home answers: a phone may not run the app's tab while the home is in front, so the hello can go unheard. It MUST NOT post it with any othertargetOrigin. - On
{ type: "minca:grant", grant }, checks the grant (§2.6) and resolves (for a proposal, §2.11,grantis theProposedanswer instead). On{ type: "minca:denied", reason? }, fails withreason. - Fails if the popup is closed first, or after a timeout.
The home:
- Does nothing unless
window.openeris set. - Posts
{ type: "minca:hello" }to the opener once, withtargetOrigin*. The hello carries nothing secret. Whatever page is in the opener's tab while the home waits can send a request without one; the defence is step 3, the home showing that page's origin. - Takes the first
messagewhosesourceis the opener and whosetypeisminca:request. The origin of that event, as the browser reports it, is the app's identity; the home MUST NOT trust any name the request gives instead, and MUST show the origin to the person. - If the request is malformed (§2.4), answers
minca:deniedwith a reason saying it did not understand, to that origin, and closes. - Otherwise waits for the person, then posts exactly one of
minca:grantorminca:deniedto that origin only, and closes itself. - Gives up silently if no request arrives within the timeout, or once the opener is closed.
The reference timings (popup size, polling, timeouts) are in the app-side client and the account home's side.
Source: packages/core/src/session/connect.ts (connectToHome, askHome, receiveConnectRequest, homeAddress). Tests: packages/core/tests/connect.test.ts ("the app asking its home", "the home receiving a request", "an account home typed by a person").
2.4 The request
ConnectRequest:
| Field | Type | Meaning |
|---|---|---|
v | 1 | |
audience | string | The app's key, did:key:…, or with via: code the program's (§3). The note is made out to it. |
proof | string | base64url ES256 signature by the audience key over the UTF-8 bytes of the canonical JSON {"audience":<audience>,"proof":"minca/connect-key/v1","recordsOnly":<recordsOnly>,"via":<via>}, where recordsOnly is the request's (false when absent) and via is "code" with via: code and "popup" otherwise; with via: code it also has "origin":<origin>, the origin of the home it was handed to (§3). |
name | string, ≤ 80, optional | What the app calls itself. Shown, never trusted. |
access | read | write | carry | write to change the spaces given, read only to look, carry for a carrier (§4.3). |
scope | spaces | account, optional | spaces (default): the spaces the person picks and any made for the app. account: every space, the account's list, making and joining spaces. |
create | NewSpace[], optional | Spaces the home should make for the app: at most 8, each with a non-empty name ≤ 80, visibility, and valid founding roles if given (03 §2.1). |
contacts | boolean, optional | The contacts space and the contact key. |
chooseSpaces | boolean, optional | Whether to offer the person's existing spaces. Default true. UI only. |
recordsOnly | boolean, optional | A note that grants records only (/record to write, /record/read to read; 01 §8). |
via | code, optional | The audience is a program's key that reached the home itself over a connect code (§3). Never sent by an app: the home makes such a request for itself. |
days | integer 1–365, optional | How long the note should last. The home decides; default 7. |
A home MUST refuse a request (as in §2.3 step 4) unless: it has no member
but those in the table; v is 1; audience starts with did:key:;
access is one of the three; scope, name, contacts, create, days
are absent or valid as above; when recordsOnly: true, access is not
carry, create is absent and contacts is not true; and via is absent.
A request with via comes only from the home itself (§3), never over
postMessage.
Connecting offers no automations: a person signing in has asked to be told about nothing yet. An app asks later, when they do (§2.11). A home MUST NOT add one while connecting.
Example:
{
"type": "minca:request",
"request": {
"v": 1,
"audience": "did:key:zDnaeyrPwbZxpDVLsnvAvAEGYazWB2ZrM7QL4Qb1JPzfiYpKy",
"proof": "<base64url signature>",
"name": "Todo",
"access": "write",
"scope": "spaces",
"create": [{ "name": "Todos", "visibility": "private" }],
"days": 30
}
}The bytes a key signs for proof, for an app's request with the key above:
{"audience":"did:key:zDnaeyrPwbZxpDVLsnvAvAEGYazWB2ZrM7QL4Qb1JPzfiYpKy","proof":"minca/connect-key/v1","recordsOnly":false,"via":"popup"}With via: code, for a program's key handed to the home at
https://home.example (§3):
{"audience":"did:key:zDnaeyrPwbZxpDVLsnvAvAEGYazWB2ZrM7QL4Qb1JPzfiYpKy","origin":"https://home.example","proof":"minca/connect-key/v1","recordsOnly":true,"via":"code"}Binding via and recordsOnly means a program's proof, which it hands to a
home over a code (§3), can't ask for an app's connection, nor for more than
records. An app's own proof can't be passed off as a program's either.
Binding origin means the proof is no use at any other home: the home checks
it against its own origin.
2.5 The grant
A home MUST refuse a request whose proof does not verify with the
audience key over the statement of §2.4 (with via: code, naming the
home's own origin), whose audience is the home's own session key, or whose
audience another connection of a different origin, or a different via,
already holds. Disconnecting revokes the audience key (§2.10), so a request
must never be able to name a key its sender doesn't hold.
When the person approves, the home:
-
Makes the spaces in
create, in the account (so they land in its list on every device). -
With
contactsandscope: spaces, adds the contacts space to the spaces granted. (Withscope: accountthe app derives it itself.) -
For each granted space — the ones the person picked, the ones made, and the contacts space — makes a view-only invite (for a private space it carries the key; it never carries a role secret) and, for a private space under
scope: spaces, the account's member key for that space (deriveMemberKeyBytes(accountKey, spaceId), 01), which opens that space's next key and nothing else. -
Computes the lifetime:
days= the person's choice, else the request's, else 7, within the home's own bounds;expiresAt = now + round(days × 86400)(unix seconds). -
Signs a note with the account's root key (01 §7):
aud=request.audience,exp=expiresAt,cmd=/forwrite,/recordforwritewithrecordsOnly: true(01 §8), and/record/readforread; and, by the spaces granted (grantTerms(access, scope)):scope: account→sub= the account's DID,pol=[];scope: spaces, one space →sub=space:<id>,pol=[];scope: spaces, several →sub= the account's DID,pol=[["or", [["==", ".space", "<id>"], …]]], the ids sorted;scope: spaces, none →sub= the account's DID,pol=[["not", ["and", []]]], which nothing satisfies.
The note carries no
meta. -
Remembers the connection (§2.10).
-
Answers with the grant.
Grant (the home sends it without home; the app adds home = the connect
page it opened):
| Field | Type | Meaning |
|---|---|---|
v | 2 | 2 since the note is a delegation in the UCAN 1.0 profile. An app MUST refuse any other, and drop a kept grant of another. |
did | string | The account. |
name | string | The account's name, for showing who is connected. |
note | object | The note: a token (01 §7.1). |
access | read | write | |
scope | spaces | account | |
spaces | { id, name, invite, memberKey? }[] | Granted spaces; memberKey is base64url. |
accountKey | base64url, optional | With scope: account: the account key (01 §11.1). It opens every private space and the account registry, but cannot sign as the account or open the home's local storage. |
contactKey | base64url, optional | With contacts or scope: account: the contact key's secret. Never with recordsOnly: it opens knocks, in shared spaces and in the account's inbox (07). |
contactsSpace | string, optional | With contacts and scope: spaces: which of spaces is the contacts space. |
relays | string[], optional | Relays the home uses; the app joins them too, so the two always share one. |
expiresAt | number | Unix seconds. |
home | string | Added by the app. |
Example (shortened):
{
"type": "minca:grant",
"grant": {
"v": 2,
"did": "did:key:zDnaemCBstFw5hzNdNggFHZm6GpHoHqNGrwEtrVi9CxGH9V7E",
"name": "Leif",
"note": {
"payload": {
"aud": "did:key:zDnaeyrPwbZxpDVLsnvAvAEGYazWB2ZrM7QL4Qb1JPzfiYpKy",
"cmd": "/",
"exp": 1792614901,
"iss": "did:key:zDnaemCBstFw5hzNdNggFHZm6GpHoHqNGrwEtrVi9CxGH9V7E",
"nbf": 1790022901,
"nonce": "pr0q7Vm5c2T8xkWa",
"pol": [],
"sub": "space:blm7z6…"
},
"sig": "…",
"type": "minca/ucan/dlg@1"
},
"access": "write",
"scope": "spaces",
"spaces": [{ "id": "blm7z6…", "name": "Todos", "invite": "…", "memberKey": "q0v…" }],
"relays": ["wss://relay.minca.social"],
"expiresAt": 1792614901
}
}Rationale: the note limits writing, per space (and, with a policy, per collection), and every peer checks it. It cannot limit reading a private space it was given: reading is holding the space's key. Invites are view-only because what lets the app write is the note, under the account's own role — never a secret of the space's.
Source: packages/core/src/session/auth.ts (grant, audienceProblem), packages/core/src/session/connect.ts (ConnectRequest, Grant, GRANT_VERSION, grantTerms, isRequest, keyProof, checkKeyProof). Tests: packages/core/tests/connect.test.ts ("connecting an app to an account home", "an app can only ask for a key it holds, and none the home already uses for something else", "a request with a field it does not know is refused…").
2.6 What the app checks
Before using a grant the app MUST check that v is 2, that the note
verifies as a note (01 §7.5), that its aud is the
app's own key, and that its iss is grant.did. An app that asked for
recordsOnly: true MUST refuse a note whose command is not under /record,
as a program connected by code MUST (§3).
2.7 Starting a connected node
The app's node signs with the app's own key (§2.1) under the granted note,
joins the relays in grant.relays besides its own, and joins each granted
space with its view-only invite and, when given, its memberKey. Without an
account key it sees only the spaces it was given; with one it follows the
whole account (§1.3). How the reference starts it is in
the app-side client.
Source: packages/core/src/session/connect.ts (startConnectedNode, nodeOptionsFromGrant, joinGranted, grantSigner), packages/cli/src/app-node.ts (startDesktopNode), packages/cli/src/agent.ts (startWithKey). Tests: packages/core/tests/connect.test.ts.
2.8 Scope
scope: spaces | scope: account | |
|---|---|---|
| Note | about the one space, or the account with a policy naming them | about the account, no policy |
| Account key | no | yes |
| Sees | the granted spaces | every space; the account registry |
| Makes or joins spaces | the home makes them at grant time | itself; they land in the account's list |
| Contacts | only with contacts: true: reads the list and knocks, changes neither, opens no inbox | yes, including ask/accept and the inbox |
| Member keys | one per granted private space | derived from the account key |
With recordsOnly the note grants /record (or /record/read) under either
scope, so every peer refuses from it what a records-only note can't do
(01 §8): it never changes a space's collections or who may
do what, and never writes in the account's own spaces. To make something new
its key proposes collections for someone who may define them (the library's
blueprints, blueprints and guides).
A node whose note can't change access never names relays, seals member keys
or rotates a space key; in a private space it only learns keys. With
scope: account it still gets the account key, so the contact list is within
its note to read; a home MUST NOT give it the contact key, with which it could
open knocks left for the account, in shared spaces and in its inbox
(07). What the library also ignores from such a key (knocks, and calls
from a key that writes records here and nothing more) is in
contacts and
calls.
Source: packages/core/src/session/connect.ts (grantTerms), packages/core/src/identity/abilities.ts (noteTerms), packages/core/src/session/auth.ts (grant), packages/core/src/identity/abilities.ts, packages/core/src/node/space-history.ts (buildEvent), packages/core/src/node/space-standing.ts (judgeStanding), packages/core/src/node/space-writing.ts (write), packages/core/src/node/space-keys.ts (upkeep). Tests: packages/core/tests/records-only.test.ts ("a definition signed by hand under a records-only note is ignored by every peer", "what counts is what the note grants", "it never writes the account itself…", "in the account's own spaces, a version under a records-only note does not stand", "a program on a computer gets the whole account, records only, for as long as asked; each is its own connection"), packages/core/tests/connect.test.ts ("the grant is a note from the account to the app key, for the chosen spaces only").
Either way the app never gets the seed: it cannot sign in as the account, change
its password or passkeys. Its note stops working for live checks at
expiresAt, and its records stop counting once the account disconnects it
(§2.9, §2.10).
Planned: grants narrower than a space. Reading a private space is holding its key, so an app granted a private space reads all of it until the space's key changes (03,
changeKey). A note can already limit writing to some collections, with a policy on.collectionthat every peer checks (01 §8), but a home doesn't offer one yet; reading per collection would need collection keys the home can hand out alone. Not designed yet.
2.9 Expiry and renewal
A note is never renewed in place. When expiresAt passes, the app's note
stops working for live checks everywhere: knocks, live messages, connecting
to peers. Its records are judged with no time (02 §9.3), so they stop counting
only when its key is revoked, and only the person revokes it, by disconnecting
the app (§2.10). A home MUST NOT revoke an app's key on its own, at expiry or
otherwise: a revoke keeps only what its writer had seen of the key (03 §6.4),
and a home that is seldom open has seen little, so it would withdraw the
app's honest writes. To continue, the app connects again, which produces a new
note (and, at the home, replaces the old connection record).
Known defect: connections are kept in the browser that granted them, so only that home can disconnect an app, and only while it is signed in. Tracked in #165.
2.10 Connections and revocation
The home remembers each connection it grants: the app's origin, audience,
access, scope and the spaces granted. Connecting the same origin again replaces
its connection; a connection with via: code is kept by its audience, apart
from the app's own, and connecting the same key again replaces it. A carrier
(§4.3) is kept apart too: an app connecting replaces no carrier, and a carrier
connecting replaces only an earlier carrier from its origin, never the app's
connection. Disconnecting is the same: disconnecting an app leaves a carrier
at its origin, and a carrier is disconnected by its key. The home MUST refuse
a request for a key already connected as the other kind, so one key is never
both.
Disconnecting an app, or a program connected by code, revokes the key of each connection removed
that has access: write (never the home's own session key), and every key it
replaced. Connecting the same app again with a new key revokes nothing: the
home keeps each old key that could write with the connection, with the scope
and spaces it was granted, and disconnecting revokes it where it was granted,
as below. The same key asking again for less keeps its earlier reach there,
since its earlier note still works. Such a key is no other connection's: the
home MUST refuse a request for it from any other (§2.5). Each key is
revoked:
scope: spaces: in every space it was granted that the account still writes in;scope: account: wherever the account writes: every space it lists, its carry spaces, the contacts space and the account registry. Such an app acts as the account (01 §8), so it could write in all of them.
Disconnecting an app (not a program connected by code, which is kept by its
key) also removes every automation of the account's own whose
app.origin is its origin (§2.11, §4.4), and every one with a version its
key signed, whatever app that one names.
Revoking writes a sys.revoke record naming the key (03 §6.4).
From then on nothing that key signed counts, except what the revoker had
already seen of it. What the app could already read, it keeps. How the reference
home stores connections and what the reference client does when it sees its
key revoked are in the account home's side.
Source: packages/core/src/session/auth.ts (disconnect, revokeConnection, revokeWhere, connections, grant, grantCarry), packages/core/src/node/node.ts (revokeKey, checkRevoked). Tests: packages/core/tests/connect.test.ts ("disconnecting …", "disconnecting a whole-account app revokes it in the carry spaces too…", "connecting again with a new key revokes nothing…", "a key a connection replaced is not granted again for anything else", "a carrier and an app at one origin are two connections…").
2.11 Proposing automations
"Let me know when…" is an automation of the account's own: what the account
wants to hear about, kept in its registry. Only an app offers one, and only
when the person asks it to: never while connecting (§2.4), never a carrier or
a program connected by code, and never the home on its own. The home lets the
person pause and remove what they kept, not add to it. The app uses the
exchange of §2.3 with a ProposeRequest in place of the ConnectRequest:
| Field | Type | Meaning |
|---|---|---|
v | 2 | |
kind | "propose" | Tells it apart from a ConnectRequest, which has no kind. |
name | string, ≤ 80, optional | What it calls itself. Shown, never trusted. |
automations | Proposal[] | 1 to 8 automations to offer the person. |
A Proposal has no members but these:
| Field | Type | Meaning |
|---|---|---|
name | string, 1–120, not blank | What the person sees, and what the notification says unless then does |
when | { query, others? } | query is { collection, where? } and nothing else; others, a boolean, default true |
then | { kind: "notify", text } | Optional. text is 1–500 characters, not blank |
spaces | string[], 1–256, optional | Only these of the spaces the app may reach (step 2 below) |
open | string, optional | Where a click leads: an https:// address (or http:// on localhost) on the request's origin |
collection is a collection name (02 §6.1) that does not
start with sys.. where is a filter over one record: an object whose
members are body field paths, link:<role>, @key, @collection,
@createdBy or @createdAt, each a value to equal or an object of the
operators $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin,
$exists, $contains; or $and, $or (lists of filters) and $not (a
filter), nested at most 16 deep. The string "$me" anywhere in it stands for
the account. What these operators mean is the library's
(query format); the home checks only
the shape, and shows the person name and the spaces.
A home MUST refuse a request with a kind as malformed (§2.3 step 4) unless
it has no member but those in the table, v is 2, kind is propose,
name is absent or valid, and automations has 1 to 8 entries, each a valid
Proposal.
When the person approves some or all of the proposals, the home:
- Finds the connection it keeps for the request's origin (§2.10), other than
one with
via: codeor a carrier's. With none it MUST refuse. - Works out what the app may reach: every space (
"all") underscope: account, else the spaces granted without the contacts space (none: it refuses). - Checks every kept proposal before writing any. It MUST refuse the whole
proposal when one names in
spacesa space outside that reach. - Adds each kept proposal to the account's own automations
(
sys.automation, 03 §13.2), naming the app:spacesis the proposal's own, else the reach;opendefaults to<origin>/;appis{ origin, name? }withnamethe connection's, else the request's, cut to 80. One the same origin already has that asks for the same thing is not added again. - Answers
minca:grantwithProposed:{ v: 2, kind: "proposed", automations: { id, name }[], versions? }, the automations added or found; empty when the person kept none. For an app withscope: accountthat kept at least one,versionsMUST hold every version the home keeps of each of them (sys.automation, 03 §13.2), as stored in the registry: signed, their bodies sealed. For any other app it MUST be absent.
An app given versions SHOULD store them at once, each only once it passes
every check a version arriving from a peer would (05),
and only versions of sys.automation in its own registry. The home's window closes when it answers, often before the
two have met on the network; without them, the automations would reach the
app only when it next meets another device of the account.
Nothing else changes: the app gets no access it did not have, no note, and learns only which of its suggestions were kept, and a whole-account app only what it could already read.
Example:
{
"type": "minca:request",
"request": {
"v": 2,
"kind": "propose",
"name": "Chat",
"automations": [
{
"name": "New message in Club",
"when": { "query": { "collection": "std.message" }, "others": true },
"spaces": ["blm7z6wkumdjs43sxki3lv27z5x6qtwa345vmlzjco2mxxau3jmhq"]
}
]
}
}answered with
{
"type": "minca:grant",
"grant": {
"v": 2,
"kind": "proposed",
"automations": [{ "id": "automation:k3v6mzq4ha2tmbyx", "name": "New message in Club" }]
}
}The app shows the notifications itself; how the reference matches records against the account's own automations is in the app-side client.
Rationale: the home writes them, not the app. What the account wants to hear about is the person's intent: the app knows good collections and names, the person says yes, and says no later in one place for every app.
Rationale: a carrier cannot read what it would notify about, and a name it chose from the kinds of record it sees go by would be a guess at the person's words. An app they are looking at knows what its records mean.
Source: packages/core/src/session/connect.ts (ProposeRequest, Proposed, proposeToHome, isRequest), packages/core/src/session/auth.ts (propose, automationsFrom), packages/core/src/session/connection.ts (propose), packages/core/src/node/node-carriers.ts (automations.versions, automations.take), packages/core/src/node/space-runtime.ts (versionsOf, take), packages/core/src/space/own-automations.ts (checkProposal, proposalSpaces, fromProposal, sameAutomation), apps/home/src/components/ConnectPage.tsx (ApproveProposal). Tests: packages/core/tests/connect.test.ts ("an app proposing automations", "the home receiving a request"), packages/core/tests/topics.test.ts ("a proposal has only the members the home checks").
Planned: a wallet as the account home. The same job — hold the root and hand an app's key a note — done by a credential wallet through the Digital Credentials API instead of a popup page. Nothing is designed beyond that: open are what the wallet would hold (the seed, or a key the account's note delegates to) and how the request and grant of §2.4–2.5 would map onto a credential request.
3. Connecting by code
A program with no browser of its own (a command in a terminal, a server, an
AI's MCP server) connects to an account at the person's account home. The
program makes a one-time code and opens the home's connect page with it
(minca connect); the two meet on the code's channel, the home says where it
lives, the program says which key it holds, the person signs in, or makes an
account, and allows it, and the home signs a records-only note made out to
that key. No app takes part. The protocol does not care what the program is.
The code and the link. mc_ followed by a 16-byte secret in base64url
(22 characters). The program MUST make the secret with a CSPRNG each time it
connects. It opens, or shows, the home's connect page (§2.2) with the code as
the fragment, so it never reaches a server:
https://home.example/connect#mc_eYV4XGMvhkky6OWpPfqfRQ. The home reads the
first match of mc_([A-Za-z0-9_-]{22}) not followed by another base64url
character, decoding to 16 bytes, and SHOULD take it out of the address bar.
The channel. The secret is the secret of a channel of kind connect by
code (01 §15.2): room CID(UTF-8("minca/code-room/v1") ‖ secret),
keys under minca/code-key/v1, sealed messages under minca/code/v1 bound to
{ type, seq }. The home is the offering side, the program the joining side.
Both join that room on a shared relay (04; introductions off)
under a throwaway did:key, and talk over the resulting peer connection.
For the secret eYV4XGMvhkky6OWpPfqfRQ (base64url), the room is
bihn55ne2az53tlealtmk3ervpyk6xa6364ii25ogc7xv54zkjszq.
Messages. Network messages { type, from, payload }. The first three
are in the clear; the rest are sealed (payload is the base64url envelope of
the UTF-8 JSON body).
type | From | Payload |
|---|---|---|
code:hello | program | { "commit": base64url(SHA-256(J)) }, to each peer that connects, until a code:heard opens |
code:key | home | { "key": base64url(O) }, to each peer that said hello, until one asks |
code:point | program | { "key": base64url(J) }, to each peer that sent code:key |
code:home | home | sealed { "origin": "<the home's origin>" }, once the point matches the commitment; origin ≤ 200, a serialized origin such as https://home.example |
code:ask | program | sealed { "did": "<its key>", "proof": "<keyProof, via code, origin>", "name": "…" }; did starts did:key:, name ≤ 80, proof ≤ 200 |
code:heard | home | sealed {} |
code:answer | home | sealed { "grant": <Grant> } or { "denied": "<reason>" } |
code:done | program | sealed {} |
The check. Both ends derive six digits from the exchange, as pairing's code (01 §15.2) but under their own label:
check = uint32_be(HKDF(ikm, "minca/code-check/v1" ‖ transcript, 4)) mod 1000000,
as 6 decimal digits, zero-paddedThe home shows it to the person; the person gives it to the program, typed in its terminal or told to the AI that runs it. Only the two ends of one exchange share it.
Home Program
makes mc_…, joins room,
opened at /connect#mc_…, joins room opens the home with it
◀─────────────── peer connected ───────────────▶
◀── code:hello { commit: H(J) }
code:key { key: O } ──▶
◀── code:point { key: J }
checks H(J)
code:home sealed { origin } ──▶
◀── code:ask sealed { did, proof, name }
code:heard sealed {} ──▶
shows the check; the person signs in,
allows it, and gives the program the check
code:answer sealed { grant } ──▶
checks the grant, and
waits for the check
◀── code:done sealed {}- One exchange each side, as in pairing. With several, someone in the room could run many with each side and pick a pair whose checks match. Each side MUST make a new ephemeral key for it, and MUST NOT use a point one peer has seen with another.
- The home answers the first
code:hellowithcode:key, and MUST ignore every later one. It answers that peer'scode:pointwithcode:homeif the point hashes to its commitment, and MUST ignore one that does not. Once that program'scode:askopens and reads as above, it is the one asked about, and the code is spent: the home MUST NOT answer another ask, and MUST ignorecode:donefrom any other peer. It sendscode:heardat once, and MUST show the person the name the program gives, as a name only, and the check. - What it gets is a note that grants records only, in the whole account
(§2.8), for as long as the home decides. The home makes the
ConnectRequest(§2.4) itself, withaudience= the ask'sdid,proof= itsproof,via: "code",recordsOnly: true,access: "write"andscope: "account", and grants it as §2.5 says, checking the proof against its own origin. It MUST NOT grant a program by code anything but records, and MUST NOT send a grant made out to another key. - The program sends its commitment before it sees any
code:key, and its point only after. It answers acode:homeonly when itsoriginis the origin of the home it opened, withcode:askon that exchange, its proof naming that origin. It sendscode:pointonly to the first peer whosecode:keyit takes, and MUST ignore every latercode:key; that peer, once itscode:heardopens, is the home, ignoring every other from then on. It MUST check a grant as an app does (§2.6):vis 2; the note verifies;audis its own key;issisgrant.did; and the note's command is under/record. It MUST NOT keep the grant, nor sendcode:done, until the person has given it the check and it matches its own. Then it sendscode:doneand keeps the grant, withhomethe connect page it opened, never the one the grant names. It SHOULD show the persongrant.didbesidegrant.name, which is only what the answer says.
What the reference home shows, the program's timeouts, and how the CLI picks the home and keeps its key and grant are in agents.
Rationale: the relay introduces the two sides and could sit between them, so everything after the key exchange is sealed. The code is in the key and so is the shared point: someone without the code gets a different channel, and someone who learns it later, holding a recording, still lacks the point. A key per direction and a count in each message mean nobody in the room can turn one side's message into the other's, relabel it or repeat it. Someone else who holds the code can take either side's one exchange first, so the program is never answered; that costs a retry, not the account. The code travels from the program to the browser it opens, in the fragment, so no server sees it. The account is not in the code, since the program does not know it until the person has signed in; so someone else who holds the code could answer first, saying they are that home, and hand the program a note from their own account. The check stops that: the person's own home shows nothing for an exchange it isn't in, so the person has no check to give, and the program keeps nothing. The program commits to its point before it sees the home's, and the home sends its point before it sees the program's, in one exchange each with points made for it, so nobody between them can pick a key to make two exchanges show the same check; one guess in a million is left. The check protects the program, not the account: the home sends the note when the person allows it, and a page posing as a program gets it then. Keeping the home it opened means no answer decides where it connects next time.
Source: packages/core/src/session/connect-code.ts (newConnectCode, connectCodeUrl, connectCodeIn, readConnectCode, answerConnectCode, receiveConnectCode, connectWithCode), packages/core/src/identity/channel.ts (CODE_CHANNEL), packages/core/src/session/connect.ts (checkGrant, keyProof), packages/core/src/session/auth.ts (grant), apps/home/src/components/ConnectPage.tsx. Tests: packages/core/tests/connect-code.test.ts ("the room for a code, and the link that carries it", "the code alone does not open a recorded exchange", "a recording of the room, and the code learned later, open nothing", "a relay that relabels or repeats messages changes nothing", "a code works for one program: a second with the same code is not answered", "saying no reaches the program; a note for another key, or for more than records, is refused", "the program asks only the home it opened, and keeps that home, whatever the answer names", "a home opened at a link nobody waits on any more says so", "the program keeps its note only once the person gives the code the home shows; a point that breaks its commitment is ignored", "each exchange has keys of its own on both sides, so seeing one tells nothing about another"), packages/core/tests/records-only.test.ts ("a program connected by code, through the account home": "over a code: the program learns the home’s origin, and its proof is refused at any other"), packages/core/tests/connect.test.ts ("an app can only ask for a key it holds, and none the home already uses for something else", "a request with a field it does not know is refused…").
3.1 Planned
Planned. Not normative.
- Programs that cannot run code of their own (a hosted chat's connectors, a phone app) would need an HTTPS endpoint somewhere else, such as on a relay, which would then see the traffic. If offered at all, it is a separate, clearly labelled option. Not designed.
4. Carriers and hosts
4.1 What a carrier holds, and sees
A carrier keeps an account's spaces online and backed up without being able to read them. It holds:
- its own P-256 key (its DID is what peers and keepers lists name);
- the key of its carry space, which holds nothing but passes and wakes;
- for each carried space, a pass: the space's constitution and, for a private space, its read key seed;
- the records of each carried space exactly as they travel.
It holds no seed, no space key, no invite secret and no note: it signs nothing,
and writes nothing. It runs every record it takes in through the same gates
as a member's node, but in a private space it cannot read bodies, so it
skips the checks that read them: onePer, fixed and tags
(02 §7.3, §7.4, §8.4). It accepts what passes the
rest, as any peer without the key does; peers that hold the key still judge
those checks. On the wire it shows no note, so peers see it as from: null.
It can see: which spaces exist, their size and when they change, every record's outer details (author, collection, times, topic tags; 02), and which account asked it to carry which space. It cannot read a private space's bodies or links, and cannot tell a topic tag's value.
The read key in a pass only proves to peers that the carrier may take part in a private space (04); it is derived one way from the space key and decrypts nothing (03).
Source: packages/core/src/space/pass.ts, packages/core/src/node/carrier.ts. Tests: packages/core/tests/carrier.test.ts ("passes", "a carrier").
4.2 Carry spaces and passes
Using a carrier needs the account key:
- The node derives the carry space from the account key, the carrier's
didandgen: 0 for a carrier new to the account, else one more than the highestgenof thesys.carrierrecords it holds for thatdid(03 §13.1, §14). Agenthat is not a non-negative integer names no carry space: such a record is ignored. - It writes a
sys.carrierrecord in the account registry, keycarrier:<hex of the first 20 bytes of SHA-256(utf8(carry space id))>, body{ did, name, since, gen? }(genonly when not 0). - It fills the carry space (below) and returns
{ space, invite }, whereinviteis a view-only invite to the carry space; the carrier joins with it. Keeping the other carriers' carry spaces in step (below) needs no waiting for.
Every device derives the same carry space, so two devices that add a carrier
at once, each before hearing of the other's record, write one record for one
space. Nothing is stored before step 2, so a node that stops before it
leaves nothing behind. A space is a carry space when a sys.carrier record
of the account's, live or removed, is keyed by its id: the key alone decides
it, whatever the body says. A node never lists one as a space the account
uses, nor leaves it.
A carrier the account already uses, by did, is not added again: the node
returns its carry space and invite as they are. So a carrier that connects
again keeps what it holds, and is never sent carry:closed (below) for a
space it is still using. One added again after it was removed gets a new
carry space, one gen past its removed record's.
A sys.carrier record from before carry spaces were derived names its carry
space and a view-only invite to it in space and invite; a node opens that
space with the invite. No node writes one.
A removal made on a device that is offline can cross a reconnect made
through another device that has not heard of it yet. The removal wins: once
it arrives, the carrier reads carry:closed, forgets what it held and says
the account disconnected it, and connecting it again then derives a new carry
space. That device could not tell the carrier was already removed, so nothing
it does at the reconnect could prevent this.
Every device holding the account key keeps each live carrier's carry space in
step with the account (§1.3): one sys.pass record per space, and one
sys.wake per automation of the account's own (03 §15).
It writes each pass from the key its registry holds for the space, and opens
the space, which is how it learns of a change of key; the refill that change
starts writes the pass for the new key. It writes no pass in the
carry space of a carrier its registry shows removed, and a removal on that
device waits for a refill already under way.
Known defect: a device behind on a key change writes the pass from the key it still holds, over a pass for the later key that another device wrote; the carrier then proves the old read key and stops carrying the space until a device that has caught up refills (#283).
Pass records. Collection sys.pass, key
pass:<hex of the first 20 bytes of SHA-256(utf8(space id))>, body SpacePass:
| Field | Meaning |
|---|---|
v | 1 |
space | The space's constitution (Space, 03). |
read | Private spaces: the read key seed (deriveReadSeed(spaceKey)), base64url. |
readKey | Present once the space's key has changed since it began: the read key's DID, which the space's history (not its constitution) vouches for. |
Passes are written for the account registry, the contacts space, and every
space in sys.joined that this device holds with its key (a private space held
without its key gets none until the key arrives). Passes for other spaces are
deleted.
A carrier MUST accept a pass only if the space hashes to its id and, for a
private space, the read seed's DID equals space.readKey or the pass's
readKey. Only pass records verified as written by the carried account count.
When two accounts name one space, a pass for a later key wins over one for the
first key.
Removing a carrier: delete every pass, write sys.pass key carry:closed
with body { "v": 1, "closed": true }, then write the sys.carrier record
again with removed (the time), rather than deleting it. A
carrier that reads carry:closed from the account MUST stop carrying for it and
forget what it held. The node keeps the carry space open until it closes, so
the carrier hears it (the node).
Links. Beside the passes, the device writes each sys.link record of
the account's registry (03 §13.2) into the carry space, same
key and body, and deletes those the registry no longer holds. A carrier that
runs a relay (a host) answers a mailbox fetch (07 §5) of a
topic with the blob of every such record whose topic, computed from the blob
itself (03 §7.7), is that topic, until its until: first, on the first page,
with seq 0. The account never names a topic, so its records can't make the
host answer for an inbox's topic or for another link's. The reference host
serves an account's 64 most recently written. A host leaves nothing in any
other relay's mailbox.
A pass cannot be taken back. A removed carrier that does not forget keeps the
read key seeds it was given, so it can still prove itself to peers and fetch
ciphertext until each space's key changes (03, changeKey).
Removing a carrier does not change any key by itself.
Keepers. A node holding the account key that is granted /space/keepers in a space names the account's live carriers as
its keepers ({ did, name }, at most 16, 03) and stops naming
carriers the account removed. Other keepers stay. So apps holding part of a
space (05 §5) can rely on the carriers.
Source: packages/core/src/node/node-carriers.ts (carriers, syncPasses, passAll, nameKeepers), packages/core/src/node/carrier.ts (linksIn, linksAt), packages/cli/src/host.ts (mailboxFrom), packages/core/src/space/pass.ts, packages/core/src/space/account-registry.ts. Tests: packages/cli/tests/host.test.ts ("a short link: the host keeps it…"), packages/core/tests/carrier.test.ts ("added twice at once, it gets one carry space", "added on two devices at once, it gets one carry space…", "added again after it was removed…", "its carry space cannot be left…", "adding one that fails leaves no carry space behind…", "removed while a refill runs, no pass is written back", "a removal reaches the carrier even when the home closes straight after").
4.3 Connecting a carrier through the account home
A carrier (a browser extension) asks the home with access: "carry" (§2.4),
from a page that stays open until the answer comes. The home removes any
earlier carrier from the same origin with another audience, adds the
carrier (§4.2) with did = the request's audience, and answers with a
CarryGrant:
{
"v": 1,
"kind": "carry",
"did": "<account>",
"name": "Leif",
"carry": { "space": "<carry space id>", "invite": "<view-only invite>" },
"pod": { "dataPath": "accounts/k3j2h4g5f6d7/stores", "folder": "Minca" },
"relays": ["wss://…"]
}pod is null when the account lives in the home's browser storage. The
carrier MUST check that kind is carry, that the invite is to the named
space, that the space is private, with did its one founder and no roles
assigned
(03 §1.1), and that the invite carries
its key; and that pod.dataPath has no empty or .. segments. A carry
connection has no expiry and no note to revoke; disconnecting it removes the
carrier (§4.2).
The home's window is often the only node that holds the new sys.carrier
record and the passes, and nothing else hears of the carrier until one does.
So after answering, the home SHOULD stay open until its node has been level
with the carrier (nothing left in flight, 05) in
the carry space and then in the account registry, after writing the passes,
or a while has passed (the reference home waits at most 30 s in all). Once level, the carrier
holds its passes and its own record, and hands both to the account's other
devices as they come online.
Source: packages/core/src/session/connect.ts (connectCarrier, checkCarryGrant, CarryGrant, receiveConnectRequest), packages/core/src/session/auth.ts (grantCarry), packages/core/src/node/node-carriers.ts (carriers.reached, carriers.settled), apps/home/src/components/ConnectPage.tsx (ApproveCarrier, ApproveProposal). Tests: packages/core/tests/connect.test.ts ("connecting a carrier to an account home"), packages/core/tests/carrier.test.ts ("a home that closes once the carrier is reached…", "the same carrier connecting again…").
4.4 Wakes, and carriers
What the account wants to hear about is kept as automations of its own, in the account registry (§2.11). An app proposes them when the person asks it to, and the home adds the ones the person keeps, naming the app. Disconnecting an app removes them (§2.10). The app shows what they match itself, while it runs (§2.11).
Each device holding the account key copies each into every carry space as a
wake (03 §15), and a carrier matches arriving versions
against the wakes there, from their outside alone. A carrier on one of the
account's own devices may show a match there itself, saying no more than it
sees: which space, which kind of record, and the wake's open. None sends a
match to another device yet: that is what the wakes are for (below).
Source: packages/core/src/space/own-automations.ts, packages/core/src/node/node-carriers.ts (automations, syncPasses), packages/core/src/node/carrier.ts (arrived), packages/cli/src/carry.ts (runCarrier), apps/shared/src/notify.ts (createNotifier), apps/extension/src/worker.ts (notify). Tests: packages/core/tests/carrier.test.ts ("notifications through a carrier"), apps/desktop/tests/notify.test.ts.
Planned: devices deliver wakes, by Web Push. Issue: #60. The account's own automations are the account's and apps propose them (above); which device shows a match is not modeled yet. Today only an app that is running shows a match (§2.11), so nothing reaches a device where it is closed, and an app with
scope: spaces, which cannot read the registry, shows nothing. The plan:
- Each device registers as a receiver in its own record, apart from wakes:
push: { endpoint, p256dh, auth }(the browser'sPushSubscription, for the app that proposed the automation) anddevice(a name for people). Wakes hold no device or endpoint data.- By default every receiver gets every wake; a device can opt out on its own. A removed device's receiver record goes with it.
- A carrier that stores a new version that wakes a wake (03 §15) sends a Web Push to each receiver's endpoint: one HTTPS POST, VAPID-signed (RFC 8292), payload encrypted to the device (RFC 8291). The payload is the version as the carrier holds it (private body still encrypted) when it fits in about 3 KB, otherwise its id and space.
- The push carries RFC 8030's
Topicheader, derived from the version id, so when several carriers send the same record the push service replaces undelivered copies. The notification'stagis the record key, so duplicates that get through replace each other.- The app's service worker (installed as a PWA on a phone: Android, and iOS 16.4 or later from the home screen) opens the space key from its own storage, decrypts, checks the rest of the filter (
whereincluded), and shows the notification. Browsers require every push to show one (userVisibleOnly), so a device cannot quietly drop most of a stream: an automation whose wake is only a busy collection with no topic tag SHOULD be refused when made, naming the fields to mark as topics.The push service (chosen by the browser) sees timing and size, never content. A carrier learns a device's endpoint and how often a tag matches. Depends on nothing else in this part.
Open: a push subscription is bound to one sender's VAPID key (
applicationServerKey). Several carriers pushing to one device need either one push subscription per carrier (the device subscribes with each carrier's key; a host's description would list it asvapid) or carriers sharing a key. Also open: which device copies wakes into carry spaces; where receiver records live and which carrier sends to which endpoint; whether "not on this device" syncs.
5. Calls
Calls are not protocol. A call is live messages (§9.2 of 04)
between copies of an app, plus WebRTC connections of the app's own; no peer
syncs, stores or judges a record differently because of it. The call.*
messages two calling apps agree on, and how the reference library rings,
joins and leaves, are in calls.
05 — Sync and storage
How two peers that hold the same space find out which record versions each lacks and exchange them, and how what a node holds is kept where others can read it: the store and its entries, the data…
07 — Inbox
A DID is a name, not an address: it is on everything an account signs, so knowing it must not be enough to reach the account. Two people who share a space can become contacts through it, with a…