~ knotless Search/
↑↓ move↵ open⇧↵ all results
0
0
main
512 lines · 32.1 KB Raw History
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."