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
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
plan: "0034"
title: "WebRTC guest transport: a kind on vm.onNetwork, compute.events.guest.* with an age publicKey handshake, a guest event subscription on GuestControlPlane, a pion guest agent served from the bidder, ContractEvent, and wootty removed"
status: doing
source: >
2026-10-24, the user's decisions (binding), read against main at 0c8eed53.
Goal. A requester (the CLI, and the kcp-wasm browser page) reaches its guest over a WebRTC
datachannel instead of an opentunnel URL, which costs 70-165 s of ACME DNS-01 per tunnel
(plan 0033 L-learnings). The guest runs a pion agent; the browser uses RTCPeerConnection; the CLI
uses pion. Signaling rides the existing market.event path, sealed with age so no SDP, ICE
candidate or IP address is ever readable in a public record.
The gateway's binaries route today. internal/gatewayhttp/downloads.go serves
GET /downloads/{sha256}/{name} (line 156) from PinnedDownloads: LoadPinnedDownloads(dir) reads
every <name> with a sha256sum-format <name>.sha256 sidecar, verifies the hash at load and keeps
the bytes in memory; responses are immutable. The dir is the release asset
socialweb-computer-k8s-downloads (internal/stack/gatewaykcp/gatewaykcp.go:73, built by
scripts/build-web-app-downloads.sh), handed to GatewayAPI as ComponentStorage.BlobsDir
(internal/app/gateway/gateway.go:431-437); GatewayRequiredDownloads (gateway.go:53) are the meshd
binaries; k3s join cloud-init fetches $BASE/downloads/<sha>/socialweb-node-<arch>
(internal/atproto/cloudinit/k3sjoin.go:252).
The bidder side today has no downloads. GuestControlPlane serves /v1/auth/{mount}/{step},
/v1/identity/oidc/token/{role}, OIDC discovery and keys, POST /v1/on-network and health
(internal/services/guestcontrolplane/routes.go:22-27). Guests fetch opentunnel from its GitHub
release (versions.AssetFetches, internal/market/requester/tunnels.go:50-62) and wootty as a GHCR
OCI layer (versions.GuestDownloadPins, tunnels.go:63-75; cloudinit/tunnels.go:325-366); the
requester puts those pins into user_data.
onNetwork today. lexicons/com/publicdomainrelay/temp/compute/events/vm/onNetwork.json is
{address, tag?, createdAt, signatures}: no tag means address is the iroh ticket, a tag names an
entry of ComputeContract spec.tunnels (types_computecontract.go:51, listMapKey tag) and address is
its https URL. The guest's guest-onnetwork.service (internal/guestseed/userdata.go:166-266, one per
tag) takes a token through guest-login.sh and POSTs {acceptUri, acceptCid, address, tag} to
/v1/on-network; guestidentity.Guest.OnNetwork (internal/guestidentity/service.go:221) verifies the
bearer with g.verifier.Verify into claims.ComputeVM, the recorder
(internal/guestidentity/guestrecord.go:100-150) refuses a tagged address that is not https and
keeps it on the ComputeVM guest report; the bidder publishes each through a StateSync
(internal/market/bidder/report.go, syncs.go reportSyncName/reportSyncKey "onNetwork:<tag>:<receipt>")
as a signed market.event; the requester parses it (internal/market/requester/events.go:75-134,
isGuestEventNSID at 46) and upserts status.contract.networks (tunnels.go:108-130,
runner.go:916-1016). A tag is a name, not a type.
The event NSIDs today, all under com.publicdomainrelay.temp.: market.{rfp,bid,bids.*,accept,
accepts.*,receipt,receipts.*,event,heartbeat,offering,relays,submitRfp,submitBid,submitAccept,
submitEvent}, compute.vm, compute.events.vm.{delete,hostKeys,onNetwork,registerIdentity,started,
terminated}, cluster.*. market.event is an envelope {receipt, payload strongRef, signatures}; the
payload NSID is the event type. The bidder's applyMarketEvent (internal/market/bidder/submit.go:128-155)
acts on vm.delete only and drops every other payload. There is no guest namespace.
The ingress constraint. The did:key ingress proxy carries an HTTP request as one buffered
request/response frame pair (internal/atproto/ingress/client.go responseBody), so an SSE response
through the guest relay would not stream; subscriptions stream natively as subscriptionEvent
frames (client.go:389-420, DirectSubscriptionHandler). Hence a WebSocket XRPC subscription, not SSE.
principle: >
The guest is a party to signaling but holds no repo key: everything it says goes through the
bidder, which signs it, and everything said to it comes from the requester through the bidder. The
bidder routes by payload NSID prefix and never reads a sealed body. No private key, and no
plaintext SDP, enters kcp, a PDS, a log or a plan. Every signal is sealed to a publicKey the
recipient published under the same contract.
decisions:
- id: D1-GUEST-NAMESPACE
text: >
New payload NSIDs under com.publicdomainrelay.temp.compute.events.guest.*. Direction comes
from the author: a requester-authored event under this prefix is forwarded by the bidder to the
guest's subscription; a guest-originated one is signed and published by the bidder to the
requester. The market.event envelope is unchanged; vm.* stays the bidder's own.
- id: D2-PUBLICKEY-HANDSHAKE
text: >
A generic encryption handshake: compute.events.guest.publicKey {kind, tag, key, createdAt,
signatures}. kind knownValues ["age-x25519"]; tag names the tunnel (or another purpose) the key
is for. Both sides publish one per tag; anything sealed under that tag is sealed to the latest
publicKey of the other side. Reusable beyond WebRTC.
- id: D3-AGE
text: >
age (filippo.io/age, X25519 recipients) in the guest agent and the CLI; typage
(github.com/FiloSottile/typage) in the browser. Sealed bodies are age armored ciphertext.
- id: D4-SIGNAL
text: >
compute.events.guest.signalWebRTC {kind, tag, ciphertext, createdAt, signatures}. kind
knownValues ["offer", "answer"]. Non-trickle ICE: the requester offers once with all
candidates, the guest answers once. The sealed plaintext is {sdp, replyTo}: an offer is sealed
to the guest's latest publicKey for the tag and carries in replyTo the age recipient the answer
is sealed to. No record names the key it is sealed to (2026-10-24, the user's decision): a
reader opens every signal for its tag with its identity and keeps those that open, so public
records never link a signal to a session key and two sessions on one tag cannot mix answers.
- id: D5-ONNETWORK-KIND
text: >
vm.onNetwork gains kind, knownValues ["iroh", "https", "webrtc"]. Absent kind reads as iroh
when tag is absent and https when tag is present, so old records stay valid. kind webrtc: tag
names the tunnel and address is the guest agent's DTLS certificate fingerprint
("sha-256 AB:CD:..."), which the requester checks against the answer's a=fingerprint.
- id: D6-STUN
text: >
Public STUN only, and only stun:stun.cloudflare.com:3478 (no Google, no TURN). A
symmetric-NAT failure is reported, not worked around.
- id: D7-SSHD
text: >
A webrtc tunnel carries raw TCP to a guest port; the shell is the guest's sshd on 22. The
browser runs golang.org/x/crypto/ssh in wasm over the datachannel; the CLI is an ssh
ProxyCommand. Host keys come from vm.hostKeys as today; the requester's ssh key is in
spec.sshAuthorizedKeys at RFP time.
- id: D8-TRANSPORT
text: "spec.tunnels[].transport: opentunnel (default) | webrtc."
- id: D9-GUEST-STREAM
text: >
GuestControlPlane serves the XRPC subscription com.publicdomainrelay.temp.compute.guest.subscribeEvents
(WebSocket, cursor param), streamed through the ingress DirectSubscriptionHandler and the local
handler registry, authenticated by the guest bearer (the same verifier as /v1/on-network).
- id: D10-GUEST-EVENTS-OUT
text: >
POST /v1/on-network becomes POST /v1/events {acceptUri, acceptCid, record}: record.$type is
vm.onNetwork, guest.publicKey or guest.signalWebRTC. /v1/on-network is removed in the same change; the
guestseed reporter moves to /v1/events.
- id: D11-BIDDER-BINARIES
text: >
GuestControlPlane serves GET /v1/downloads/{sha256}/{name} from a bidder downloads asset
(opentunnel and socialweb-guest-agent, x86_64 and aarch64). The bidder injects the install
module with its own pins, like the guest-onnetwork module, so pins always match what it serves;
the requester only asks for tunnels and transports. Guests stop fetching from GitHub or GHCR.
- id: D12-CONTRACTEVENT
text: >
New cluster-scoped socialweb.computer/v1alpha1 ContractEvent: spec {contractRef, direction
(outbound|inbound), nsid, kind, tag, key or ciphertext}, status {record, event, submit
{endpoint, attempts, lastAttemptAt, submittedAt, lastError}, conditions Published, Submitted}.
The browser and the CLI create outbound ones with kubectl; the requester creates inbound ones
from submitEvent or Jetstream (oauth-browser). ownerRef to the ComputeContract.
- id: D13-WOOTTY-GONE
text: >
wootty is removed in the first wave: spec.tunnels[].shell, ComputeContractShell, its pins,
units and scripts, and its docs. opentunnel https tunnels stay.
- id: D14-CLI-AND-BROWSER
text: >
Both the CLI requester (pion) and the browser requester (kcp-wasm peer) are built and accepted.
The browser side is the peer's, through a subagent it launches; the lead briefs it only after
this plan is approved.
- id: D15-SAFARI
text: "The Safari indigo atproto/syntax stack issue stays with the peer's build-time patch; not in this plan."
state_rules:
- "Private age identities live only in process memory (guest agent: /run tmpfs 0600 at most; browser and CLI: memory). Never in kcp, a PDS, a log or a StateSync."
- "ContractEvent and StateSync hold only public material: publicKeys and age ciphertext."
- "The bidder's per-VM guest queue lives in the GuestControlPlane state dir, not kcp; it is dropped when the ComputeVM goes."
- "No polling: the subscription keepalive is an outbound obligation listed in deploy/obligations.txt; the guest reconnects with backoff on close."
items:
- id: W0-WOOTTY-REMOVE
title: "Remove wootty"
depends_on: []
decision: >
Delete ComputeContractShell and spec.tunnels[].shell, the wootty guestDownloads pin, the wootty
parts of cloudinit/tunnels.go (WoottyBinary, WoottyRunPath, DefaultShellEnvFile,
ShellTokenMinLength, the shell units) and requester tunnels.go (errShellWithoutSecrets, the
wootty pins), the --tunnel shell form in clicfg, and the wootty text in AGENTS.md,
docs/ARCHITECTURE.md, docs/compute-provider-requirements.yaml, NEXT.yaml and the catalog row
TestAGuestShellTunnel (rewritten for https only). Plan 0032 stays as history.
files:
- api/v1alpha1/types_computecontract.go
- deploy/computecontract-apiresourceschema.yaml
- internal/versions/versions.json
- internal/atproto/cloudinit/{cloudinit.go,tunnels.go}
- internal/market/requester/tunnels.go
- internal/clicfg/options.json
- AGENTS.md
- docs/ARCHITECTURE.md
- docs/spec/integration-catalog.yaml
done_when: "rg -i wootty outside docs/plans and docs/research is empty; go generate ./... leaves no diff; go build ./... and go vet ./... pass; make check-pins passes."
status: done
- id: W1-LEXICONS
title: "onNetwork kind, compute.events.guest.{publicKey,signalWebRTC}, compute.guest.subscribeEvents"
depends_on: []
decision: >
Add kind to vm.onNetwork (D5). New records guest.publicKey (D2) and guest.signalWebRTC (D4) with
maxLength bounds (key 256, ciphertext 16384). New subscription compute.guest.subscribeEvents
{params: cursor integer; message #event {seq, uri, cid, record}; errors FutureCursor}.
Regenerate internal/atproto/market (lexgen include list gains compute.guest.).
files:
- lexicons/com/publicdomainrelay/temp/compute/events/vm/onNetwork.json
- lexicons/com/publicdomainrelay/temp/compute/events/guest/{publicKey,signalWebRTC}.json
- lexicons/com/publicdomainrelay/temp/compute/guest/subscribeEvents.json
- internal/atproto/market/{nsid.go,zz_generated.lexicons.go}
done_when: "go generate ./internal/atproto/market/... leaves no diff after the commit; old onNetwork records parse with the D5 defaults."
status: done
- id: W1-API
title: "ContractEvent CRD, spec.tunnels[].transport, network kinds"
depends_on: [W0-WOOTTY-REMOVE]
decision: >
ContractEvent per D12 with printer columns (contract, direction, nsid, tag, phase). transport
on ComputeContractTunnel (D8). kind on ContractNetworkAddress, ComputeVMGuestNetwork and
ComputeVMPublishedNetwork. Export contractevents in the applications APIExport and binding so
the browser kcp and the CLI can kubectl it.
files:
- api/v1alpha1/types_contractevent.go
- api/v1alpha1/{types_computecontract.go,types_computevm.go,types_nodeclaim.go,resources.go,zz_generated.deepcopy.go}
- deploy/*apiresourceschema.yaml
- deploy/*apiexport*.yaml
done_when: "go generate ./... leaves no diff; kubectl create of a ContractEvent in a dev requester workspace succeeds; go build ./... passes."
status: done
- id: W2-BIDDER-DOWNLOADS
title: "GuestControlPlane /v1/downloads and the bidder downloads asset"
depends_on: []
decision: >
Move PinnedDownloads out of internal/gatewayhttp into internal/kit/pinneddownloads (the gateway
keeps its route); GuestControlPlane registers GET /v1/downloads/{sha256}/{name} from its
ComponentStorage.BlobsDir. New release asset socialweb-guest-downloads built by
scripts/build-guest-downloads.sh: opentunnel (from its pinned release tarball, verified) and
socialweb-guest-agent-{x86_64,aarch64} (CGO off), each with a sidecar. The bidder app hands the
dir to GuestControlPlane; a missing asset fails the bidder's config validation.
files:
- internal/kit/pinneddownloads/
- internal/gatewayhttp/downloads.go
- internal/services/guestcontrolplane/{routes.go,config.go}
- internal/app/compose/ (bidder GuestControlPlane storage)
- scripts/build-guest-downloads.sh
- internal/versions/versions.json
- .github/workflows (binaries job stages the asset)
done_when: "From a guest: GET <issuer>/v1/downloads/<sha>/socialweb-guest-agent-x86_64 returns bytes whose sha256 is <sha>; a wrong sha answers 404."
status: done
- id: W2-BIDDER-INSTALL-MODULE
title: "The bidder injects the guest binary install module"
depends_on: [W2-BIDDER-DOWNLOADS]
decision: >
W2-BIDDER-DOWNLOADS found that the did:key ingress cannot carry binaries: the relay wraps a
response body as a JSON string (non-UTF-8 bytes become U+FFFD, headers drop) and frames are
capped at 8 MB (opentunnel overflows and drops the subscriber). Guests run on the bidder's
own host and already reach its loopback at guestnet.HostAddress 192.0.2.1 (pasta
--map-host-loopback), so downloads go there: a local Exposure of the GuestControlPlane
download handler on a PortRegistry loopback lease, and the guest fetches
http://192.0.2.1:<port>/v1/downloads/<sha>/<name>; sha256 is the integrity check.
guestseed gains GuestBinariesModule(base, pins): setup-guest-binaries.service, a oneshot at the
front of runcmd that fetches each binary, verifies sha256 and installs to /usr/local/bin. The
bidder adds it when the contract has any tunnel (opentunnel for transport opentunnel,
socialweb-guest-agent plus socialweb-guest-agent.service, ConditionPathExists
/etc/socialweb-guest-agent/tunnels.json, for transport webrtc). The requester's tunnel module
carries only config (W4).
files:
- internal/guestseed/userdata.go
- internal/market/bidder/provision.go
- internal/services/guestcontrolplane/
- internal/app/compose/ (the downloads Exposure)
done_when: "A tunnel contract's rendered user_data fetches only from http://192.0.2.1:<port>/v1/downloads; the guest's journal shows each binary installed with its sha256."
status: done
- id: W2-GUEST-EVENTS-OUT
title: "POST /v1/events replaces /v1/on-network"
depends_on: [W1-LEXICONS]
decision: >
GuestControlPlane POST /v1/events {acceptUri, acceptCid, record}, the same bearer check.
record.$type vm.onNetwork keeps the ComputeVM guest report path (now with kind); guest.publicKey
and guest.signalWebRTC go to a StateSync per (collection, tag, kind, sha256 of the record) and are
published by the bidder as signed market.events to the requester with the durable submit of
plan 0026 (backoff, window). The bidder validates shape and size only; it never opens a
ciphertext. The guestseed reporter posts to /v1/events.
files:
- internal/services/guestcontrolplane/routes.go
- internal/guestidentity/{service.go,guestrecord.go}
- internal/guestcp/
- internal/market/bidder/{report.go,syncs.go,submit.go}
- internal/guestseed/userdata.go
done_when: "The bidder's repo holds a guest.publicKey payload in a signed market.event bound to the receipt, and the requester's ingress received it through submitEvent."
status: done
- id: W2-GUEST-EVENTS-IN
title: "The bidder forwards requester guest.* events into a per-VM queue"
depends_on: [W1-LEXICONS]
decision: >
applyMarketEvent: a payload under compute.events.guest. whose receipt is this bidder's and
whose issuer is the accept author is verified (signature, receipt, size) and appended to the
VM's queue (seq, uri, cid, record) in the GuestControlPlane state dir before 200; duplicates by
uri#cid are dropped; vm.delete handling is unchanged; any other payload is still ignored.
files:
- internal/market/bidder/submit.go
- internal/guestcp/ (queue)
done_when: "A requester guest.publicKey submitEvent answers 200 and lands once in the VM's queue file; a replay of it does not append."
status: done
- id: W2-GUEST-SUBSCRIBE
title: "compute.guest.subscribeEvents on GuestControlPlane"
depends_on: [W2-GUEST-EVENTS-IN]
decision: >
A DirectSubscriptionHandler on the guest relay Exposure and a WebSocket route on the local
handler. Auth: the guest bearer in the first frame or Authorization, verified to a ComputeVM.
Replays queue entries after cursor, then streams new ones; the guest's next cursor acknowledges
and prunes. Keepalive every 25 s, listed in deploy/obligations.txt. The queue is removed when
the ComputeVM is deleted.
files:
- internal/services/guestcontrolplane/
- internal/exposure/registrar.go (only if the subscription handler needs a hook)
- deploy/obligations.txt
done_when: "A guest subscription with cursor 0 receives a requester publicKey sent before it connected and one sent after; reconnecting with the last seq receives nothing old."
status: done
- id: W3-GUEST-AGENT
title: "cmd/socialweb-guest-agent (pion + age)"
depends_on: [W1-LEXICONS, W2-GUEST-EVENTS-OUT, W2-GUEST-SUBSCRIBE]
decision: >
One binary and one unit, started when any tunnel has transport webrtc. At start: generate an
age X25519 identity (memory) and one DTLS certificate (memory, stable for the agent's life);
take a token with guest-login.sh token <audience>; for each webrtc tunnel POST guest.publicKey
and vm.onNetwork {kind webrtc, tag, address <fingerprint>}; open subscribeEvents. On a
requester publicKey: remember it per tag. On a signal offer sealed to our key: open, build a
pion PeerConnection with public STUN (D6), wait for gathering, seal the answer to the
requester's key, POST guest.signalWebRTC answer. Each datachannel bridges to 127.0.0.1:<port> of its
tunnel; many sessions per tag. pion/webrtc v4 and filippo.io/age pinned in go.mod.
files:
- cmd/socialweb-guest-agent/
- internal/guestagent/
- internal/atproto/cloudinit/tunnels.go (agent unit and config)
- go.mod
done_when: "On a webrtc contract guest, socialweb-guest-agent is active, the requester sees guest.publicKey and onNetwork kind webrtc for the tag, and journal shows the subscription open."
status: done
- id: W4-REQUESTER-CONTRACTEVENT
title: "The requester publishes and receives ContractEvents"
depends_on: [W1-API, W1-LEXICONS]
decision: >
A ContractEvent controller in the requester: an outbound one writes its payload record and a
market.event bound to the contract's receipt, then submitEvent to the bidder with the durable
submit (status.submit, Submitted). Inbound guest.* events (the requester ingress submitEvent,
or Jetstream for oauth-browser) become inbound ContractEvents named from the event uri, ownerRef
to the contract, SSA writer requester. events.go accepts the new NSIDs and onNetwork kind;
status.contract.networks carries kind.
files:
- internal/controllers/contractevent/
- internal/market/requester/{events.go,tunnels.go,runner.go}
- internal/services/requester/
done_when: "kubectl create of an outbound publicKey ContractEvent reaches Submitted; the guest's publicKey appears as an inbound ContractEvent; both are gone after the contract is deleted."
status: done
- id: W4-CLI-WEBRTC
title: "run request proxy and ssh over WebRTC from the CLI"
depends_on: [W3-GUEST-AGENT, W4-REQUESTER-CONTRACTEVENT]
decision: >
socialweb-computer run request proxy <invocation> --tag <tag>: an in-memory age identity;
create the outbound publicKey ContractEvent; wait (watch) for the guest's publicKey and the
onNetwork kind webrtc; pion offer with public STUN, sealed, as an outbound signal; wait for the
inbound answer, open it, check a=fingerprint against the onNetwork address, open one datachannel
and pipe stdin/stdout. run request ssh --transport webrtc uses it as ProxyCommand with the
contract's host keys as known_hosts. Done by subagents and accepted on local, hat-9 and hat-7.
files:
- cmd/socialweb-computer/request.go
- internal/market/requester/session.go
- internal/webrtcproxy/
done_when: "ACC34-CLI-SSH passes on all three bidders."
status: done
- id: W5-BROWSER-PEER
title: "Browser requester over WebRTC (kcp-wasm peer)"
depends_on: [W4-REQUESTER-CONTRACTEVENT, W3-GUEST-AGENT]
decision: >
The peer launches a subagent for the browser side: typage identity in memory; kubectl in the
browser kcp creates the outbound publicKey and signal ContractEvents; watch inbound
ContractEvents (Jetstream in oauth-browser); RTCPeerConnection with public STUN; fingerprint
check; x/crypto/ssh in wasm over the datachannel with an in-memory ed25519 key put in
spec.sshAuthorizedKeys at request time; xterm.js. The lead briefs the peer over the :7720/:7721
link after approval and answers its questions.
files:
- "peer branch origin/kcp-wasm-memory-storage"
done_when: "ACC34-BROWSER-SSH passes from the peer's page against all three bidders."
status: done
- id: W6-DOCS
title: "AGENTS.md transport rule, ARCHITECTURE section, catalog rows"
depends_on: [W4-CLI-WEBRTC]
decision: >
AGENTS.md and ARCHITECTURE: guest transports are iroh, opentunnel https and webrtc; the guest
namespace and the handshake; sealed signaling. Catalog rows TestAGuestWebRTCSSH and
TestAGuestEventSubscription for plan 0027. No Go test here.
files:
- AGENTS.md
- docs/ARCHITECTURE.md
- docs/spec/integration-catalog.yaml
done_when: "The files state the rule; the catalog holds both rows."
status: done
- id: ACC34-DOWNLOADS
title: "Guests install from the bidder only"
depends_on: [W2-BIDDER-INSTALL-MODULE]
done_when: "A webrtc contract guest installs socialweb-guest-agent and an https contract guest installs opentunnel from http://192.0.2.1:<port>/v1/downloads; the guest makes no request to github.com or ghcr.io (journal and bidder logs)."
status: todo
- id: ACC34-HANDSHAKE
title: "publicKeys cross both ways and signals stay sealed"
depends_on: [W3-GUEST-AGENT, W4-REQUESTER-CONTRACTEVENT]
done_when: >
Both publicKeys arrive as market.events; every guest.signalWebRTC record in the requester's and the
bidder's repo is age armored ciphertext; no record, ContractEvent, StateSync or log line holds
"a=candidate", "a=fingerprint" with an IP, or an AGE-SECRET-KEY.
status: todo
- id: ACC34-CLI-SSH
title: "CLI ssh over WebRTC"
depends_on: [W4-CLI-WEBRTC]
done_when: >
run request --hold with a webrtc tunnel {tag ssh, port 22} reaches Held on local, hat-9 and
hat-7; run request ssh --transport webrtc runs "uname -a; id" with host key checking on; time
from Held to shell is recorded; release ends Released and leaves no ComputeVM.
result: >
2026-10-25, bidders rc112, CLI dev build of main afb5f790, requester signed in as alice by
-- --atproto-oauth-qr (qr.fedfork.com scope fixed in typescript-helpers 2474c4b). Each host bid
alone (the other two suspended) and won: hat-9 did:plc:4vcig7s4..., hat-7 did:plc:ocnuqjlz...,
local did:plc:2l2wdry7... (same host and LAN as the CLI, through the forwarded LAN UDP port).
Both run request and the second run request ssh printed uid= over webrtc with
StrictHostKeyChecking=yes. ssh connect to return: hat-9 5.3 s then 1.9 s, hat-7 2.4 s then 36.1 s,
local 9.2 s then 39.5 s (the second, after a resume, is slow on two hosts; open). No a=candidate
or AGE-SECRET-KEY in any log. Every release exited 0 and left no ComputeVM.
status: done
- id: ACC34-BROWSER-SSH
title: "Browser ssh over WebRTC"
depends_on: [W5-BROWSER-PEER]
done_when: "From the peer's page: sign in, request with Bidders all three, shell over WebRTC shows uid=, release confirmed by requester_released."
result: >
2026-10-25, iPhone on cellular, page from main ae7d112f: vmshell-qfj4xu found by discovery (no bidderDids; all three bidders bid, local won), Held at 01:23:19Z, guest agent ICE connected 1.3 s after the offer, root prompt over the datachannel. On home Wi-Fi (same LAN as local) ICE failed: Safari offers only mDNS .local host candidates and the router does not hairpin srflx<->srflx; the wootty fallback (opentunnel, ~2.5 min) worked. Same-LAN WebRTC is an open networking decision for the user (LAN host candidate or TURN).
Same LAN, after the LAN host candidate (6cd77e35, bidders rc112+): 2026-10-25 06:01Z, iPhone on home
Wi-Fi, page from main 54f32470 running the socialweb event controllers, comms Firehose, no ingress,
sealed secrets. vmshell-nvmxak: local won, Held 06:02:48, secretsRequested then onNetwork, selected
pair host/udp to host/udp, root prompt, Released 06:03:34. Open: the answer arrived 27 s after the
offer because only a page-made jetstream stream ran (the requester composer's relay, us-west and
per-bidder PDS streams were not composed on the page).
status: done
- id: ACC34-UNCHANGED
title: "iroh and https paths unchanged"
depends_on: [W2-GUEST-EVENTS-OUT, W2-BIDDER-INSTALL-MODULE]
done_when: "A plain run request reaches Held with an iroh vmAddress; an https tunnel contract gets its opentunnel URL as onNetwork kind https; the rc acceptance doc passes with 0 FAIL, with every prod bidder on bidder-tangled-vouch and the requester alice vouched by the bidder operators bob and john (docs/acceptance/0001-auth-modes.md, Bidder policy)."
status: todo
- id: W7-GUEST-FENCE
title: "Guests reach host loopback only on spec.hostPorts; no LAN or host address; lanUDP and hostUDP allowances"
depends_on: [W2-BIDDER-INSTALL-MODULE]
decision: >
User decisions 2026-10-24: the guest reaches its bidder host only on the GuestControlPlane local
port (nf_tables bridge table loaded by socialweb-vmexec, fails closed); TCP to the LAN, the host's
own addresses, CGNAT, link-local and metadata is dropped; UDP to RFC1918 high ports (except SSDP,
mDNS, LLMNR) is allowed by default (spec.guestNetwork.lanUDP, bidder operator only) so same-LAN
WebRTC works over STUN only; UDP to the host's own high ports is a separate opt-in
(spec.guestNetwork.hostUDP), on by default only for the bidder-only-me policy.
hostUDP includes the host's Tailscale/CGNAT addresses on purpose (user, 2026-10-25): it is on by
default only for only-me bidders, whose renter is the operator, who may come from their own tailnet.
done_when: "Built (17945ac8, c8148e9e, 8d4a6a6f) and verified on dev networks; prod after rc110."
status: done
- id: W8-RELAY-GO
title: "The xrpc.fedproxy.com relay is the Go ingress relay"
depends_on: []
decision: >
socialweb-computer internal ingress-relay replaces the deno relay (scripts/deploy-xrpc-relay.sh,
drop-in 30-go.conf, --rollback). Subscriptions forward Authorization and atproto-proxy; binary
frames accepted; eviction only after 90 s of silence; disconnects log code, reason, duration.
done_when: "Deployed d33616e3 then 64491ad2; the browser page stays registered."
status: done
- id: W9-OAUTH
title: "Sessions missing new scopes ask to sign in again; stale URLs reissued; never Ready as another account; sign out"
depends_on: []
decision: >
Scope check per producing flow (721de1bb, 11aabf78); oauthBrowser stale URL and AccountMismatch
(d3d48e13); spec.signOutGeneration and the bsky.social entryway (b1c62e42, 199ace77).
done_when: "Built; prod after rc110: bob (hat-7) and alice (page, gateway) re-sign."
status: done
- id: W10-CONTRACT-TEMPLATE
title: "One ComputeContractTemplate for ComputeContract and Infra contracts; the page uses one EphemeralInfra per session"
depends_on: [W1-API]
decision: "63be6130; Infra contracts carry tunnels incl. shell; record lexicon checked against the Go type; Infra always published (656152e2)."
done_when: "Built; the page applies the agreed shape after rc110."
status: done
- id: W11-SEALED-SECRETS
title: "Sealed secrets over guest events, explicit opt-in and on demand; getSecrets stays primary"
depends_on: [W3-GUEST-AGENT]
decision: "spec.secretsFallback None|Sealed; the guest asks only when getSecrets fails or the requester has no ingress (564efb6e)."
done_when: "Built and verified on dev networks."
status: done
- id: W12-WOOTTY-FALLBACK
title: "wootty is back as the browser fallback shell, served from the bidder"
depends_on: [W11-SEALED-SECRETS]
decision: "User reversed D13: the page tries ssh over webrtc first, then opens the opentunnel wootty link (7d5cfe0c)."
done_when: "Built and verified on a dev network."
status: done
- id: W13-DISCOVERY
title: "Requesters discover bidders without a pinned list: ported TS sources, public relays, bidder crawl requests, per-bidder streams, comms Both"
depends_on: []
decision: >
The Go rewrite kept only bidderDids and a market-network relay query; prod ran on a pinned list.
Port vouched, operated (badgeBlueKeys bidder_associate both ways), offering watch and
market.relays sources; default discovery relays; bidders requestCrawl their PDS; derive one PDS
stream per discovered bidder; requesters read Both by default.
done_when: "The gateway and the page find local, hat-9 and hat-7 with no bidderDids."
result: >
Ported vouched, operated (bidder_associate both ways), market.relays and public relay discovery; per-bidder streams; comms defaults to Both. On prod rc110 dev: discovery found hat-7 via bsky's relay and local and hat-9 as bob's operated bidders.
status: done
- id: REL-RC110
title: "rc110 to local, hat-9, hat-7 and the gateway; re-sign bob and alice; drop the gateway's pinned bidderDids"
depends_on: [W13-DISCOVERY]
done_when: "All roots Ready on rc110; hat-7 and the gateway owner identities Ready after re-sign; discovery logs show relay/operated sources."
result: >
rc110 dev builds of main (d0446143, hat-7 at 2f02ad9c) installed with --allow-dev-binary on local, hat-9, hat-7 and the gateway; bob re-signed on hat-7 through spec.signOutGeneration and the scripted QR. Not yet tagged/published; gateway bidderDids not yet dropped.
status: doing
open_questions:
- "None blocking. Defaults taken: requester offers and guest answers; CLI age identity in memory per proxy session (not OpenBao); ContractEvent outbound is the only direction users create."