1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
# guest-events: events under a receipt
- after the receipt ([market.md](market.md)), the bidder, the requester and the guest talk only through `market.event` records and two guest HTTP paths
- steps: [S7](README.md#step-s7) boot and vm events, [S8](README.md#step-s8) guest events and atirc, [S9](README.md#step-s9) release
- one row per lexicon: [lexicons-market.md](lexicons-market.md)
- constants: `internal/atproto/market/nsid.go`, `internal/atproto/market/guest.go`; guest side: `internal/guestagent` (binary `cmd/socialweb-guest-agent`); guest HTTP plane: `internal/services/guestcontrolplane`, `internal/guestidentity`
- names drop the prefix `com.publicdomainrelay.temp.` (`guest.publicKey` = `com.publicdomainrelay.temp.compute.events.guest.publicKey`)
## The Envelope
- every event is two signed records in the sender's repo: the payload (`compute.events.vm.*` or `compute.events.guest.*`) and `market.event {receipt, payload, createdAt, signatures}`
- `receipt` = strongRef to the bidder's `market.receipt`; `payload` = strongRef to the payload record in the same repo as the event
- the receiver reads the payload through the strongRef, checks its CID, its author and both signatures (`applyGuestEvent` in `internal/market/bidder/guestevents.go`)
- delivery: `market.submitEvent` to the other party, and the relay / Jetstream streams as fallback ([market.md](market.md#delivery-xrpc-callbacks-and-streams))
- the guest holds no repo key. the bidder signs and writes every guest-originated event in its own repo
## Who Sends What
| payload | originator | written in | consumer | file of the consumer |
|---|---|---|---|---|
| `vm.onNetwork` | guest (iroh ticket and https tunnel URL from the `guest-onnetwork` script, webrtc fingerprint from the guest agent) or bidder (sandbox) | bidder repo | requester: `status.contract.vmAddress`, `status.contract.networks` | `internal/market/requester/runner.go` `waitGuest` |
| `vm.hostKeys` | bidder (the host key the guest bound at its workload identity login) | bidder repo | requester: `status.contract.hostKeys` | same |
| `vm.sandbox` | bidder (deno sandbox ended) | bidder repo | requester: `status.contract.sandbox` | `internal/market/requester/events.go` |
| `vm.terminated` | bidder | bidder repo | requester: Released | `internal/market/requester/market.go` `FindTermination` |
| `vm.delete` | requester | requester repo | bidder: release | `internal/market/bidder/adapters.go` `applyRelease` |
| `guest.publicKey` | either side | sender repo | the other side | guest: `internal/guestagent/subscribe.go`; requester: inbound ContractEvent |
| `guest.signalWebRTC` | either side | sender repo | the other side | same |
| `guest.secrets` | requester only | requester repo | guest | `internal/guestagent/secrets.go` |
| `guest.secretsRequested` | guest only | bidder repo | requester: seals the bundle | `internal/market/requester/guestsecrets.go` |
| `guest.secretsApplied` | guest only | bidder repo | requester: condition `SecretsApplied` | `internal/market/requester/events.go` |
| `guest.secretsUpdated` | requester only | requester repo | guest: fetches the bundle again | `internal/guestagent/subscribe.go` |
| `guest.agentStart` | requester only | requester repo | guest: `systemctl start --no-block atirc-agent.service` | `internal/guestagent/agentstart.go` |
| `guest.requesterDid` | guest only | bidder repo | requester: `status.contract.guestRequester` | `internal/market/requester/association.go` |
| `guest.r2Start` | requester only | requester repo | guest: starts `socialweb-r2.service` | `internal/guestagent/association.go` |
| `guest.r2Release` | requester only | requester repo | guest: runs `socialweb-r2-release.service` | same |
| `guest.r2Released` | guest only | bidder repo | requester: `status.contract.r2Release` | `internal/market/requester/guestrequester.go` |
| `vm.started`, `vm.registerIdentity` | defined, no Go writer | none | none | `internal/atproto/market/nsid.go` constants only |
- guest-originated set: `market.IsGuestOriginatedEventNSID` (publicKey, signalWebRTC, secretsApplied, secretsRequested, requesterDid, r2Released). the guest control plane refuses any other kind (`guestEvent` in `internal/guestidentity/service.go`)
- requester-originated set: `market.IsRequesterGuestEventNSID` (publicKey, signalWebRTC, secrets, agentStart, secretsUpdated, r2Start, r2Release). the bidder refuses any other kind from a requester (`applyGuestEvent`)
- kinds the requester's submitEvent takes: `isGuestEventNSID` in `internal/market/requester/events.go`
- direction of `publicKey` and `signalWebRTC` comes from the author: requester-authored ones go to the guest, bidder-authored ones carry what the guest said
## Guest To Requester
```mermaid
sequenceDiagram
autonumber
participant G as guest
participant CP as GuestControlPlane (bidder)
participant B as Bidder
participant BP as bidder repo
participant R as Requester
G->>CP: POST /v1/events acceptUri acceptCid record, Bearer workload token
CP->>CP: verify token, accept is this guest's, kind is guest-originated
CP->>B: vm.onNetwork onto ComputeVM status, other kinds PublishGuestEvent
B->>B: StateSync guest-<hash> or report-<hash>, key per kind
B->>BP: payload + market.event at EventRKeys(key), both signed
B->>R: submitEvent (accept.submitEvent) or the stream carries it
R->>BP: getRecord payload by strongRef
R->>R: status.contract or inbound ContractEvent
```
- the POST body: `{acceptUri, acceptCid, record}`; the record has `$type` = the payload NSID and no signature (`internal/guestagent/events.go`)
- the bearer is the `Authorization` header only (`Guest.Events` in `internal/guestidentity/service.go`)
- answers: 200 `{ok, name}`; 401 `AuthenticationRequired` / `InvalidToken`; 400 `InvalidRequest`; 404 `UnknownGuest` / `UnknownAccept`; 503 `Unavailable`
- the bidder keys: `reportSyncKey` and `guestEventSyncKey` in `internal/market/bidder/syncs.go`; the rkeys: [market.md](market.md#rkeys)
- the bidder never opens a sealed body; it routes by NSID prefix
## Requester To Guest
```mermaid
sequenceDiagram
autonumber
participant C as CLI / browser / requester
participant R as Requester
participant RP as requester repo
participant B as Bidder
participant Q as per-ComputeVM queue
participant G as guest agent
C->>R: ContractEvent Outbound (nsid, tag, kind, key or ciphertext)
R->>RP: payload at SessionRKey contract-event + market.event at contract-event-envelope
R->>B: submitEvent
B->>RP: getRecord payload, verify event + payload signatures
B->>B: author = accept author, kind is requester-originated
B->>Q: Append uri cid record
G->>B: WebSocket subscribeEvents cursor
B-->>G: frames seq uri cid record, keepalive every 25 s
G->>G: act on the kind, save cursor
```
- outbound ContractEvents (`socialweb.computer/v1alpha1`, cluster scoped, owned by the ComputeContract): made by the CLI (`internal/stack/request/webrtc.go`), the browser page, or the requester itself (`secrets-<contract>`, `secrets-updated-<hash>`)
- published by `publishContractEvent` in `internal/market/requester/contractevents.go`; field rules in `validateGuestFields` there
- inbound ContractEvents (guest `publicKey`, `signalWebRTC`) are named `InboundContractEventName(eventURI)`
### compute.guest.subscribeEvents
| item | value |
|---|---|
| route | `GET /xrpc/com.publicdomainrelay.temp.compute.guest.subscribeEvents` on the GuestControlPlane (`internal/services/guestcontrolplane/subscribe.go`) |
| auth | `Authorization: Bearer <workload identity token>`; names the ComputeVM (`guest.Authenticate`) |
| param | `cursor`: last `seq` seen. absent = oldest entry kept |
| frame | JSON text `{"$type": "...subscribeEvents#event", seq, uri, cid, record}`; `record.$type` is a `compute.events.guest.*` NSID |
| keepalive | an empty binary frame every 25 s (`SubscribeKeepalive`) |
| error | `FutureCursor`: the guest agent resets its cursor to 0 and reconnects (`internal/guestagent/subscribe.go`) |
| reconnect | backoff 2 s doubling to 30 s |
## Each Kind
| NSID tail | key fields | notes |
|---|---|---|
| `vm.onNetwork` | `kind` iroh / https / webrtc / sandbox, `address`, `tag` | kind absent: iroh without tag, https with tag (`market.OnNetworkKind`). webrtc address = the guest agent DTLS fingerprint `sha-256 AB:CD:...` |
| `vm.hostKeys` | `keys[] {type, publicKey, fingerprint}`, `observedAt` | at most 8 keys, an ed25519 key required (`internal/guestidentity/guestrecord.go`) |
| `vm.sandbox` | `exitCode`, `timedOut`, `stdout`, `stderr` | `compute.vm.kind: deno` only (`internal/market/bidder/sandbox.go`) |
| `vm.delete` | `reason` | requester writes `released` |
| `vm.terminated` | `reason`, `exitCode`, `observedAt`, `request` | `request` -> the `vm.delete` when `reason` is `deleted_on_request` |
| `guest.publicKey` | `kind: age-x25519`, `tag`, `key` (age1...) | one per tag per side; anything sealed for a tag goes to the other side's latest key |
| `guest.signalWebRTC` | `kind` offer / answer, `tag`, `ciphertext` | plaintext `{sdp, replyTo}`; non-trickle ICE, one offer, one answer |
| `guest.secrets` | `ciphertext` | age armored, at most 65536 bytes (`MaxGuestSecretsCiphertext`); plaintext = the getSecrets JSON array |
| `guest.secretsRequested` | none | only with `secretsFallback: Sealed` |
| `guest.secretsApplied` | `via` getSecrets / sealed, `count` | |
| `guest.secretsUpdated` | none | the bundle changed (atirc mint, association ref) |
| `guest.agentStart` | none | needs `spec.agent` (`contractevents.go`) |
| `guest.requesterDid` | `did` | did:plc only, never the authority's own DID (`market.ValidGuestRequesterDID`) |
| `guest.r2Start` | `name` (DNS label), `agent`, `hold` | needs `spec.association.fromGuest` |
| `guest.r2Release` | none | |
| `guest.r2Released` | `ok`, `message` | |
## Boot: hostKeys And onNetwork
```mermaid
sequenceDiagram
autonumber
participant B as Bidder
participant G as guest
participant CP as GuestControlPlane
participant BP as bidder repo
participant R as Requester
B->>G: boot user_data (cloudinit modules + setup-guest-binaries)
G->>CP: workload identity login, binds its ssh host key
CP->>B: RecordBoundHostKey onto ComputeVM guest report
G->>G: iroh.service up, writes /run/guest-fqdn
G->>CP: guest-onnetwork POST /v1/events vm.onNetwork address = iroh ticket
CP->>B: OnNetwork onto ComputeVM status
B->>BP: report: vm.onNetwork (key onNetwork:receipt) + market.event
B->>BP: report: vm.hostKeys (key hostKeys:receipt) + market.event
B->>R: submitEvent each
G->>CP: guest agent POST vm.onNetwork kind webrtc per tag (DTLS fingerprint)
B->>BP: vm.onNetwork tag (key onNetwork:tag:receipt)
R->>R: VMAddress + HostKeys, Held after secrets
```
- the iroh report unit and its script: `internal/guestseed/userdata.go` ("Report the guest iroh ticket to the market")
- one report per kind and tag: `report` in `internal/market/bidder/report.go` publishes only what `ComputeVMPublished` does not hold yet
- Held: iroh address + host keys; with a secretsRef and no sealed fallback, also the getSecrets fetch ([market.md](market.md#sequence-rfp-to-held))
## Sealed Signaling (webrtc tunnels)
- each side publishes a `guest.publicKey` (kind `age-x25519`) per tag
- the offer is sealed to the guest's latest key for the tag, plaintext `{sdp, replyTo}`; the answer is sealed to `replyTo`
- no record names its recipient: a reader trial-opens every signal for its tag and keeps the ones that open
- no private key, SDP, ICE candidate or IP address is ever in a record, a ContractEvent, a StateSync, kcp or a log
- CLI side: `run request proxy <name> --tag <tag>` (`internal/webrtcproxy`); STUN `stun:stun.cloudflare.com:3478` only, no TURN
```mermaid
sequenceDiagram
autonumber
participant C as CLI / browser
participant R as Requester
participant B as Bidder
participant G as guest agent
G->>B: POST guest.publicKey tag t (age1 guest key)
B->>R: market.event guest.publicKey (bidder repo)
R->>C: inbound ContractEvent publicKey
C->>R: ContractEvent publicKey tag t (age1 requester key)
C->>R: ContractEvent signalWebRTC offer, sealed to guest key, replyTo = requester key
R->>B: submitEvent publicKey, signalWebRTC offer
B->>G: subscribeEvents frames
G->>G: open offer, answer, check nothing else
G->>B: POST guest.signalWebRTC answer sealed to replyTo
B->>R: market.event signalWebRTC answer
R->>C: inbound ContractEvent answer
C->>G: datachannel tag t, DTLS fingerprint = vm.onNetwork webrtc address
```
## Secrets: getSecrets Or Sealed Fallback
- `spec.secretsRef` names a bundle `[{path, value, mode?}]` in the requester's OpenBao; mode octal, at most 0600 (`ParseSecretsBundle` in `internal/market/requester/secrets.go`)
- primary: getSecrets. `setup-secrets.service` exchanges its workload identity token and GETs `/xrpc/com.publicdomainrelay.temp.compute.secrets.getSecrets/<contract>` from the requester ingress ([lexicons-market.md](lexicons-market.md#computesecretsgetsecrets))
- the requester checks the token audience `api://ATProto?actx=<requester DID>` and the subject claim (`secretsState.fetch`)
- fallback: only when the requester's `spec.secretsFallback` is `Sealed`, and only on demand. never a sealed bundle unasked: published ciphertext stays on the protocol and can be broken later
- setup-secrets gives getSecrets 90 s (`cloudinit.SecretsFetchFallbackDeadlineSec`), then touches `/run/socialweb-guest-agent/secrets.request`; comms mode Firehose has no setup-secrets, the agent asks at once
- both paths end in `/run/socialweb-guest-agent/secrets.ready`; consumers order `After=` `cloudinit.SecretsReadyUnits`
- status: `status.contract.secretsRequested`, `status.contract.secretsApplied`, condition `SecretsApplied` reason `GetSecrets` or `SealedFallback`
```mermaid
sequenceDiagram
autonumber
participant G as guest
participant CP as GuestControlPlane
participant R as Requester ingress
participant B as Bidder
G->>CP: GET /v1/identity/oidc/token/role (workload identity)
CP-->>G: token
G->>R: GET getSecrets/contract, Bearer token
alt ingress answers within 90 s
R-->>G: [{path, value, mode}]
G->>G: write files, secrets.ready
G->>B: POST guest.secretsApplied via getSecrets
else no answer and secretsFallback Sealed
G->>G: touch secrets.request
G->>B: POST guest.publicKey tag secrets
G->>B: POST guest.secretsRequested
B->>R: market.event publicKey, secretsRequested
R->>R: seal bundle to that key, ContractEvent secrets-contract
R->>B: submitEvent guest.secrets
B->>G: subscribeEvents guest.secrets
G->>G: open, write files root 0600, secrets.ready
G->>B: POST guest.secretsApplied via sealed
end
B->>R: market.event secretsApplied
```
- the requester reseals for a newer key while the request is unanswered (`sealGuestSecrets` in `internal/market/requester/guestsecrets.go`)
- a Firehose requester with `None` and a secretsRef fails the contract
- a bundle change (`guest.secretsUpdated`) makes the guest fetch again (`SECRETS_REFETCH=1 setup-secrets.sh`) or ask for the sealed fallback
## Agent Start And atirc Mint
- `spec.agent {envFile}` (default `/etc/atirc/atirc.env`) needs `spec.secretsRef`
- after `VerifyReceipt`, `ensureAtircToken` (`internal/market/requester/atirc.go`) mints with `chat.atirc.mint` ([outside.md](outside.md#chatatirc)), lease = `{receipt, termination: at-uri of the bidder's vm.terminated payload at EventRKeys(TerminationKey(receipt)), contract: accept uri}` (`contractLease`)
- the token goes into the bundle entry at envFile as `ATIRC_TOKEN`; getSecrets and the sealed fallback serve the bundle without that entry until it is minted
- every fresh mint publishes `guest.secretsUpdated`; the guest fetches again; `atirc-agent-env.path` runs `systemctl try-restart --no-block atirc-agent.service` when the envFile changed (never a start)
- `run request agent <name>` makes an outbound `guest.agentStart`; the guest agent runs `systemctl start --no-block atirc-agent.service`
- every mint is recorded under `atircMinted` in the bundle secret; status `status.atircRevokes` (Done, Pending, Abandoned)
```mermaid
sequenceDiagram
autonumber
participant R as Requester
participant A as atirc
participant V as requester OpenBao bundle
participant B as Bidder
participant G as guest
R->>R: receipt verified (ReceiptOk)
R->>A: chat.atirc.mint subhandle from the RFP rkey, lease receipt + vm.terminated at-uri + accept
A-->>R: token with explicit exp
R->>V: ATIRC_TOKEN into envFile entry, record atircMinted
R->>B: submitEvent guest.secretsUpdated
B->>G: subscribeEvents secretsUpdated
G->>R: getSecrets again (or sealed fallback)
G->>G: envFile sha256 changed, try-restart atirc-agent.service
R->>B: submitEvent guest.agentStart (run request agent)
B->>G: subscribeEvents agentStart
G->>G: systemctl start --no-block atirc-agent.service
G->>A: atirc agent joins as subhandle
```
- release: the leased token is revoked before `vm.delete` while its lease is alive; the rest in the background from a release marker ([market.md](market.md#revocation-ordering))
- when the VM ends without a release, atirc ends the token through its `vm.terminated` lease
## Guest Requester (R2) Events
- a contract with `spec.association.fromGuest` runs a requester inside the guest; full flow: [identity.md](identity.md#associations-plan-0036)
- `guest.requesterDid`: the guest reports the DID of its requester; the requester then reads `market.associationRequest` by one `getRecord` and writes the contract-scoped `market.association` ([lexicons-market.md](lexicons-market.md#marketassociation))
- `guest.r2Start` starts `socialweb-r2.service` as user `r2` (`run request r2 <name>`)
- release of the parent: `guest.r2Release` first, then wait for `guest.r2Released` (`GuestRequesterReleaseTimeout`), then atirc revokes, then `vm.delete`
## Never
- a guest never holds a repo key; a guest never posts `guest.secrets`, `guest.agentStart`, `guest.secretsUpdated`, `guest.r2Start` or `guest.r2Release`
- the bidder never opens a sealed body
- no private key, SDP, ICE candidate or IP address in any record, object or log
- no sealed bundle unasked
- no fourth guest transport (iroh, opentunnel https, webrtc only; [../SYSTEM.md](../SYSTEM.md#transports-three-never-add-one))
## See Also
- [../SYSTEM.md](../SYSTEM.md#guests), [market.md](market.md), [lexicons-market.md](lexicons-market.md), [identity.md](identity.md), [outside.md](outside.md)
- plans: `docs/plans/0034-webrtc-guest-transport.yaml`, `docs/plans/0035-atirc-agent.yaml`, `docs/plans/0036-record-association.yaml`